简介这是一套面向Python初学者与计算机视觉爱好者的答题卡智能识别判卷项目源码适合课程设计、毕业设计或项目实战练习。项目以Python为核心结合OpenCV、PIL完成图像灰度化、二值化、去噪与模板匹配并引入机器学习模型对填涂选项进行分类识别同时涉及图像特征提取、OCR、数据预处理与模型评估等环节最终通过GUI实现图像上传、识别、评分与结果输出的自动化流程。压缩包共21个文件包含16个png效果预览图、3个xml配置、1个iml工程文件与1个py主程序整体约11.54MB结构清晰便于对照学习。目前已有1320人学习下载读者可借此掌握从图像预处理到判卷输出的完整实现思路理解模板匹配与分类模型在答题卡场景中的落地方式并参考识别效果图快速验证与调试自己的代码。1. 从一张手机拍的答题卡说起Python 答题卡识别判卷到底在做什么考场收完卷子最耗人的不是监考是判卷。一个班五十份选择题涂卡歪一点、铅笔淡一点、橡皮擦不干净人工核对就得来回翻。基于 Python 的答题卡智能识别判卷项目干的就是把「拍照 → 定位 → 识别涂点 → 比对答案 → 出分」这条链路自动化。它适合两类人一类是学校老师或培训机构想用一台普通扫描仪或手机就把客观题判完另一类是正在学 OpenCV 的开发者想找一个能跑通、能改参数、能落地的实战项目。这个方向的核心不是「识别」两个字而是几何校正 涂点判定。答题卡识别翻车九成不是模型不行是透视没摆正、阈值没调对。下面我按自己实际做过的顺序把整条链路拆开讲清楚源码结构、关键参数、踩过的坑都放进去你照着能复现一套自己的判卷流程。2. 答题卡识别的技术选型为什么是 OpenCV 而不是深度学习2.1 传统视觉方案和深度学习方案的边界答题卡识别这个场景有个特点结构高度固定。一张卡上就那么几个定位黑块、几十个涂点框位置关系是死的。这种「强先验」的场景用 OpenCV 做形态学和轮廓检测比训一个检测模型划算得多。我一般会这么判断如果答题卡模板是你自己设计的定位块、题号、选项框位置都已知那就走传统视觉代码量小、CPU 就能跑、单张耗时几十毫秒如果卡是别人给的、版式五花八门、还要兼容手写那才考虑上深度学习做版面分析。热搜里常出现的python下载cv2、python安装numpy库的方法其实就指向这条传统链路——opencv-python加numpy基本就够了。维度OpenCV 传统方案深度学习方案适用版式固定模板任意版式训练数据不需要需要标注单张耗时几十毫秒CPU几十到几百毫秒GPU调参难度阈值敏感数据敏感部署成本极低需要推理环境结论很直接固定模板的答题卡别上深度学习那是给自己找麻烦。2.2 最小可跑环境与依赖安装先把环境搭起来。Python 3.8 及以上都行依赖就三个核心库。安装命令如下# 建议用虚拟环境避免污染全局 python -m venv venv # Windows 激活 venv\Scripts\activate # Linux / macOS 激活 source venv/bin/activate # 核心依赖 pip install opencv-python numpy # 可选做透视变换可视化时用 pip install matplotlib这里有个血泪经验opencv-python和opencv-contrib-python别同时装会互相覆盖导致cv2导入异常。如果你要用到cv2.aruco之类的扩展模块就只装opencv-contrib-python。装完用下面这段验证import cv2 import numpy as np print(OpenCV 版本:, cv2.__version__) print(NumPy 版本:, np.__version__) # 造一张纯白图验证基本读写没问题 img np.ones((100, 100, 3), dtypenp.uint8) * 255 cv2.imwrite(test_white.png, img) print(读写测试通过)逻辑说明np.ones生成全 1 矩阵再乘 255得到纯白图用来确认 OpenCV 的编解码链路正常。参数上dtypenp.uint8是关键OpenCV 图像默认就是 8 位无符号整型用 float 会报错。如果这步就失败先查是不是装了两个 opencv 包。2.3 图像预处理灰度、高斯模糊、边缘检测三步走拿到一张答题卡照片第一步不是直接找轮廓而是降噪。手机拍的卡有噪点、有阴影直接 Canny 会检出一堆碎边。标准流程是灰度化 → 高斯模糊 → 边缘检测。import cv2 import numpy as np def preprocess(image_path): # 读取原图 img cv2.imread(image_path) if img is None: raise FileNotFoundError(f读不到图片: {image_path}) # 1. 灰度化三通道降到单通道后续处理快 3 倍 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 2. 高斯模糊核大小必须是奇数5x5 适合 1000px 左右的图 blurred cv2.GaussianBlur(gray, (5, 5), 0) # 3. Canny 边缘检测双阈值低阈值是高阈值的 1/2 到 1/3 edged cv2.Canny(blurred, 75, 200) return img, edged img, edged preprocess(answer_card.jpg) cv2.imwrite(edged.png, edged)逻辑说明GaussianBlur的核(5, 5)是经验值图越大核越大但必须是奇数。Canny的两个阈值是玄学重灾区——75和200这组适合光照均匀的扫描件如果是手机拍的、有阴影低阈值要降到50左右否则边缘断裂后面轮廓闭合不上。判断标准很简单输出的edged.png里答题卡外框应该是连续闭合的四边形如果断成几段就是阈值太高。3. 透视校正与涂点定位把歪掉的答题卡摆正3.1 找最大四边形轮廓定位答题卡边界边缘图出来后要找答题卡的外框。思路是找所有轮廓 → 按面积排序 → 取最大的近似四边形。这一步是整个流程的地基框错了后面全错。def find_card_contour(edged): # RETR_EXTERNAL 只取最外层轮廓避免内部涂点干扰 contours, _ cv2.findContours( edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) # 按面积从大到小排序 contours sorted(contours, keycv2.contourArea, reverseTrue) for c in contours[:5]: # 只看前 5 个省时间 peri cv2.arcLength(c, True) # 轮廓周长 approx cv2.approxPolyDP(c, 0.02 * peri, True) # 多边形逼近 # 近似成 4 个点认为是答题卡 if len(approx) 4: return approx return None card_contour find_card_contour(edged) print(找到的角点数:, 0 if card_contour is None else len(card_contour))逻辑说明cv2.approxPolyDP的第二个参数0.02 * peri是逼近精度值越小保留的点越多。0.02是通用起点如果答题卡边缘有毛刺导致逼近出 5、6 个点把它调到0.03或0.04。RETR_EXTERNAL很关键用RETR_LIST会把卡内所有涂点框都算进来最大轮廓反而可能不是外框。3.2 四点透视变换把斜拍变成正拍找到四个角点后要把它们映射到一个标准矩形上这就是透视变换。难点在于角点顺序——approxPolyDP返回的四个点顺序是乱的直接喂给getPerspectiveTransform会得到一张扭曲的图。def order_points(pts): # 按 xy 和 x-y 排序稳定拿到 左上/右上/右下/左下 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上xy 最小 rect[2] pts[np.argmax(s)] # 右下xy 最大 diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] # 右上x-y 最小 rect[3] pts[np.argmax(diff)] # 左下x-y 最大 return rect def four_point_transform(image, pts): rect order_points(pts.reshape(4, 2)) (tl, tr, br, bl) rect # 计算目标宽高取两组对边的最大值 width max( np.linalg.norm(br - bl), np.linalg.norm(tr - tl) ) height max( np.linalg.norm(tr - br), np.linalg.norm(tl - bl) ) dst np.array([ [0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1] ], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (int(width), int(height))) return warped warped four_point_transform(img, card_contour) cv2.imwrite(warped.png, warped)逻辑说明order_points用坐标和与坐标差来定序这是最稳的写法比按角度算省事。width和height取对边最大值保证变换后不丢像素。warped.png应该是一张端正的答题卡如果还是歪的说明角点顺序错了回去检查order_points的四个赋值。这一步做完答题卡就「摆正」了后面所有定位都可以用固定坐标。3.3 涂点区域定位按模板切格子摆正之后涂点位置就是固定的。常见做法是先定几个定位黑块anchor用它们算出每个涂点框的坐标再逐个切出来。如果模板是你自己设计的直接按比例切最省事。def split_bubbles(warped, rows, cols, start_ratio, end_ratio): h, w warped.shape[:2] # 按比例确定涂点区域避免写死像素 y0, y1 int(h * start_ratio), int(h * end_ratio) cell_h (y1 - y0) // rows cell_w w // cols bubbles [] for r in range(rows): for c in range(cols): y y0 r * cell_h x c * cell_w cell warped[y:y cell_h, x:x cell_w] bubbles.append(((r, c), cell)) return bubbles # 假设 20 题、每题 4 个选项 cells split_bubbles(warped, rows20, cols4, start_ratio0.2, end_ratio0.8) print(切出的涂点格数:, len(cells))逻辑说明用比例start_ratio、end_ratio而不是绝对像素是为了兼容不同分辨率的输入。rows和cols要跟你的答题卡模板对齐20 题 4 选项就是20 x 4。切出来的cell是每个涂点的小图下一步判断它「涂没涂」。4. 涂点判定与判卷逻辑阈值怎么定才不误判4.1 涂点是否填涂像素占比法判断一个格子涂没涂最稳的不是看平均灰度而是看深色像素占比。因为涂点可能只涂了一半平均灰度会被白底拉高占比法则更鲁棒。def is_filled(cell, threshold0.25, dark_value128): # 转灰度 gray cv2.cvtColor(cell, cv2.COLOR_BGR2GRAY) # 二值化小于 dark_value 的算深色 _, binary cv2.threshold(gray, dark_value, 255, cv2.THRESH_BINARY_INV) # 深色像素占比 dark_ratio np.count_nonzero(binary) / binary.size return dark_ratio threshold, dark_ratio # 对每个格子判定 results [] for (pos, cell) in cells: filled, ratio is_filled(cell) results.append((pos, filled, round(ratio, 3))) # 打印前 8 个看看 for r in results[:8]: print(r)逻辑说明threshold0.25是经验起点意思是深色像素超过 25% 就算涂了。这个值要按你的卡调涂点框大、铅笔涂得满可以降到0.15框小、涂得轻可能要0.3。dark_value128是二值化分界光照暗的图要往上调比如150。判断不准时先把每个格子的ratio打出来看分布——涂了的通常集中在 0.3 以上没涂的在 0.05 以下中间那段就是需要调阈值的灰色地带。4.2 单题多涂与漏涂的处理真实场景里一个题可能涂了两个选项或者一个都没涂。这两种都得单独处理不能简单取占比最大的。def judge_question(question_cells, threshold0.25): filled_options [] for idx, cell in enumerate(question_cells): filled, ratio is_filled(cell, threshold) if filled: filled_options.append((idx, ratio)) if len(filled_options) 0: return None, 漏涂 if len(filled_options) 1: # 多涂取占比最大的同时标记异常 best max(filled_options, keylambda x: x[1]) return best[0], 多涂 return filled_options[0][0], 正常 # 假设每题 4 个选项按题分组 def group_by_question(cells, options_per_q4): groups {} for (r, c), cell in cells: groups.setdefault(r, []).append((c, cell)) # 每组按选项序号排序 for r in groups: groups[r].sort(keylambda x: x[0]) return groups groups group_by_question(cells, options_per_q4) for q in sorted(groups.keys())[:5]: ans, status judge_question([c for _, c in groups[q]]) print(f第{q1}题: 选项{ans}, 状态{status})逻辑说明judge_question返回选项索引和状态。多涂时取占比最大的作为答案同时把状态标成「多涂」方便后面人工复核。漏涂返回None。这里有个坑如果两个选项占比都接近阈值比如 0.26 和 0.24取最大可能取错所以状态标记很重要判卷结果里要把这些异常题单独列出来。4.3 答案比对与得分统计最后一步是拿识别结果跟标准答案比对。标准答案一般存成列表或从文件读。def grade(results_by_q, answer_key): results_by_q: {题号: 选项索引} answer_key: [正确选项索引, ...] correct 0 detail [] for q, correct_opt in enumerate(answer_key): stu_opt results_by_q.get(q) is_right (stu_opt correct_opt) if is_right: correct 1 detail.append({ 题号: q 1, 学生答案: stu_opt, 正确答案: correct_opt, 是否正确: is_right }) score correct / len(answer_key) * 100 return score, detail # 示例20 题标准答案 answer_key [0, 1, 2, 3] * 5 results_by_q {q: judge_question([c for _, c in groups[q]])[0] for q in groups} score, detail grade(results_by_q, answer_key) print(f得分: {score:.1f}) for d in detail[:5]: print(d)逻辑说明grade返回百分制得分和逐题明细。results_by_q的键是题号从 0 开始值是选项索引。明细里保留学生答案和正确答案方便生成错题报告。实际项目里这一步的输出通常会写进 CSV 或 Excel方便老师二次处理。5. 避坑与排查答题卡识别最常见的 5 个翻车现场5.1 现象轮廓找到了但透视变换后图是镜像的原因order_points里四个点的顺序判断反了或者输入的点本身是逆时针排列。解决在order_points后加一句校验把变换后的图存下来肉眼看一眼如果镜像把rect[1]和rect[3]对调。更稳的做法是用cv2.contourArea判断轮廓方向逆时针的先反转。5.2 现象涂点判定忽高忽低同一张卡两次跑结果不一样原因is_filled的阈值卡在灰色地带或者图像预处理时高斯核大小变了。解决固定所有参数别用自适应阈值把每个格子的dark_ratio打出来看涂了和没涂的分布有没有重叠。如果有重叠说明光照不均先做一次自适应直方图均衡cv2.equalizeHist再判定。5.3 现象手机拍的卡识别率骤降扫描件却很好原因手机拍的有透视畸变和阴影Canny 阈值不适用。解决针对手机图把 Canny 低阈值降到50并在灰度化后加一步cv2.adaptiveThreshold做局部二值化抵消阴影影响。另外拍的时候尽量让卡占满画面边缘留白越少定位越准。5.4 现象涂点框切偏了答案整体错位原因split_bubbles用的比例跟实际模板对不上或者透视变换后的图尺寸跟预期不一致。解决别用比例硬切改成先检测定位黑块anchor用黑块坐标算偏移量。如果模板固定就把warped的尺寸resize到一个标准值比如 1000x1400再按固定像素切。5.5 现象多涂判定取错学生明明涂了 A 却判成 B原因两个选项占比接近max取到了噪声更大的那个。解决多涂时不要只取最大而是把占比超过阈值的都列出来如果两个都超过直接标「多涂待复核」不自动判分。判卷系统里异常题必须留人工入口这是后悔药。6. 进阶技巧把识别准确率从 90% 拉到 99% 的几个习惯做到能跑通只是及格真正上线要的是稳定。我自己的习惯是先做参数标定再做批量验证。拿 20 张不同光照、不同角度的卡跑一遍把每张的dark_ratio分布画出来找到涂与没涂的分界点而不是拍脑袋定 0.25。第二个习惯是给每个环节留中间产物。edged.png、warped.png、每个格子的切图全部落盘。出问题时不用猜直接看是哪一步歪的。这个习惯帮我省了无数调试时间。第三个是用固定尺寸归一化。透视变换后不管原图多大统一resize到1000 x 1400这样所有坐标、比例、阈值都是固定的换一张卡不用重新调参。代价是损失一点分辨率但答题卡识别不需要高清够用。最后一个技巧是异常题单独输出。漏涂、多涂、占比在灰色地带的题全部写进一个review.csv让老师复核。自动判卷的价值不是 100% 替代人工而是把 90% 的确定题判掉把 10% 的异常题挑出来。想清楚这个定位系统就好做了。我踩过最大的坑是一开始追求「全自动、零人工」结果阈值调来调去反而把确定题也判错了。后来改成「自动判 异常复核」准确率和信任度都上来了。做这类项目别跟玄学较劲把不确定的交给流程比把参数调到极致靠谱得多。希望帮到你。本文还有配套的精品资源点击获取