资讯详情

Python+OpenCV答题卡识别实战:从预处理到填涂判定的完整方案

📅 2026/10/1 1:21:03 | 华诺云谱 👁 阅读
Python+OpenCV答题卡识别实战:从预处理到填涂判定的完整方案
简介这是一套面向高校学生与Python初学者的智能答题卡识别项目源码基于Python与OpenCV实现可直接用于毕业设计、期末大作业或课程设计。项目围绕答题卡图像处理与选项识别展开涵盖图像预处理、答题卡定位、选择题识别、学号识别及自定义模型训练等模块配有完整实验报告与答辩PPT代码注释详尽新手也能读懂。资源包共48个文件包含9个py源码文件、22张jpg测试图片、5个xml配置、2个html页面以及pptx、pdf、png等文档与素材压缩包约2.99MB结构清晰、便于部署运行。目前已有474人学习下载项目经过严格调试功能完善、界面美观、操作简单能帮助读者快速掌握OpenCV图像识别流程并直接作为高分毕设或课设参考使用。1. 答题卡识别这件事为什么值得用 PythonOpenCV 从头做一遍如果你带过学生、做过考务或者只是帮老师改过卷子就会知道答题卡识别是个典型的「看起来简单、做起来全是坑」的场景。一张 A4 答题卡几十道选择题人工批改一个班要半小时还容易看花眼。用 PythonOpenCV 做一套智能答题卡识别系统核心目标就三件事把答题卡从照片里摆正、把填涂区域切出来、判断每个选项是黑是白。这套源码加实验报告的组合适合两类人一类是正在找课程设计或毕业设计题目的学生需要一份能跑通、能写进报告的完整方案另一类是刚接触 OpenCV 图像处理的工程师想找一个有明确输入输出、能验证效果的练手项目。它不需要深度学习框架不依赖 GPU一台普通笔记本就能跑这也是它作为入门级图像处理项目的最大优势。下面我会按实际落地的顺序把从环境搭建到识别调参的完整路径拆开讲。2. 从一张手机拍的答题卡到可识别图像预处理流水线怎么搭2.1 为什么预处理决定了识别率的上限很多人拿到答题卡识别任务第一反应是直接找轮廓、数黑点。但真实场景里手机拍出来的答题卡存在透视变形、光照不均、背景干扰三大问题。如果预处理没做好后面无论怎么调阈值都是白费。常见做法是走一条固定流水线灰度化 → 高斯模糊 → 边缘检测 → 轮廓查找 → 透视变换 → 二值化。这条链路里透视变换是关键它把倾斜的答题卡「拉正」成标准矩形后续所有坐标才有意义。我一般会先用 Canny 找边缘再用cv2.findContours找最大四边形轮廓。这里有个血泪经验不要用cv2.minAreaRect直接取最小外接矩形因为答题卡有定位黑块最小外接矩形会把黑块也算进去导致裁剪偏移。正确做法是先按面积排序取最大的四个点轮廓再用cv2.approxPolyDP逼近四边形。import cv2 import numpy as np def preprocess_card(image_path): # 读取图像并统一尺寸避免不同分辨率导致参数失效 img cv2.imread(image_path) img cv2.resize(img, (600, 800)) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊去噪核大小 5x5 是经验值太大丢失边缘太小噪声残留 blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny 双阈值低阈值 50 高阈值 150 适合大多数室内光照 edged cv2.Canny(blurred, 50, 150) # 查找轮廓只取外轮廓 contours, _ cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue)[:5] card_contour None for c in contours: peri cv2.arcLength(c, True) # 轮廓逼近0.02 是精度系数越小越贴近原始形状 approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: card_contour approx break if card_contour is None: raise ValueError(未找到答题卡四边形轮廓检查背景对比度) return img, card_contour这段代码的逻辑是先统一尺寸保证后续参数可复用再通过边缘检测和轮廓逼近找到答题卡的四个角点。参数说明GaussianBlur的核大小必须是奇数5x5 在 600x800 分辨率下效果稳定Canny的 50/150 是低高阈值比 1:3 的经典配置approxPolyDP的 0.02 系数控制逼近精度太大可能把四边形压成三角形太小则保留过多噪点。2.2 透视变换与二值化把答题卡「拉正」并切出填涂区找到四个角点后下一步是确定目标矩形的宽高。这里不能随便设要根据答题卡的实际长宽比来。常见 A4 答题卡长宽比约 1.414:1但手机拍出来可能变形。我一般会取四个角点中距离最远的两个点作为宽另外两个作为高然后做透视变换。def four_point_transform(image, pts): # 将四个角点按左上、右上、右下、左下排序 rect order_points(pts.reshape(4, 2)) (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 order_points(pts): # 按 xy 和 x-y 排序确定四个角点顺序 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上 rect[2] pts[np.argmax(s)] # 右下 diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] # 右上 rect[3] pts[np.argmax(diff)] # 左下 return rect透视变换后答题卡变成标准矩形。接下来做自适应二值化把填涂的黑块和背景分开。这里有个坑全局阈值cv2.threshold在光照不均时会把阴影也变成黑色。我一般用cv2.adaptiveThreshold块大小取 11 或 15C 值取 2 到 5 之间。def binarize_card(warped): gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # 自适应阈值块大小 11C 值 4 binary cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 4) return binary参数说明ADAPTIVE_THRESH_GAUSSIAN_C比均值法更平滑适合有渐变光照的场景块大小 11 意味着每个像素参考周围 11x11 区域太大则局部细节丢失太小则噪声敏感C 值 4 是从均值中减去的常数用来微调黑白分界。3. 定位填涂区与选项分割从二值图到每个选项的坐标3.1 用轮廓层级关系找到题号和选项二值化之后答题卡上会出现大量白色区域包括题号、选项框、定位黑块。直接遍历所有轮廓会混入噪声。常见做法是利用轮廓的层级关系答题卡的填涂区通常是规则排列的矩形可以用cv2.findContours的RETR_CCOMP或RETR_TREE模式然后按面积和宽高比过滤。我一般会先找定位黑块。答题卡四角或边缘通常有实心黑方块面积较大且宽高比接近 1:1。找到定位块后就能计算出整个答题卡相对于标准模板的偏移量后续所有坐标都可以用相对位置来定位。def find_anchor_blocks(binary): contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) anchors [] for c in contours: x, y, w, h cv2.boundingRect(c) area w * h aspect_ratio w / float(h) # 定位块特征面积在 200 到 2000 之间宽高比接近 1 if 200 area 2000 and 0.8 aspect_ratio 1.2: anchors.append((x, y, w, h)) # 按位置排序取四个角的定位块 anchors sorted(anchors, keylambda a: (a[1], a[0])) return anchors[:4]参数说明面积范围 200 到 2000 是根据 600x800 分辨率下的答题卡定位块实测得出的如果你的图像分辨率不同需要按比例调整宽高比 0.8 到 1.2 是为了容忍轻微变形。3.2 按行列切分选项区域并提取填涂特征定位到答题卡边界后下一步是按题目布局切分。假设答题卡是 20 题、每题 4 个选项那么可以按等分法计算每个选项的中心坐标。但实际答题卡可能有题号偏移、选项间距不等所以更稳妥的做法是先找所有选项框的轮廓再按 y 坐标聚类成行按 x 坐标聚类成列。def extract_bubbles(binary, anchors): # 根据定位块计算答题卡有效区域 if len(anchors) 4: raise ValueError(定位块不足四个无法确定答题卡边界) xs [a[0] for a in anchors] ys [a[1] for a in anchors] x_min, x_max min(xs), max(xs) y_min, y_max min(ys), max(ys) # 在有效区域内查找所有小轮廓 roi binary[y_min:y_max, x_min:x_max] contours, _ cv2.findContours(roi, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) bubbles [] for c in contours: x, y, w, h cv2.boundingRect(c) area w * h aspect_ratio w / float(h) # 选项框特征面积 50 到 500宽高比 0.5 到 2 if 50 area 500 and 0.5 aspect_ratio 2: # 计算填涂比例白色像素占选项框面积的比例 mask np.zeros(roi.shape, dtypeuint8) cv2.drawContours(mask, [c], -1, 255, -1) fill_ratio cv2.countNonZero(cv2.bitwise_and(roi, roi, maskmask)) / float(area) bubbles.append((x x_min, y y_min, w, h, fill_ratio)) return bubbles这段代码的核心是fill_ratio它表示选项框内白色像素的占比。二值化后填涂区域是白色所以填涂越满fill_ratio越高。一般阈值设在 0.3 到 0.5 之间低于 0.3 视为未填涂高于 0.5 视为已填涂。但要注意如果考生涂得很轻可能需要降低阈值如果印刷的选项框本身有底色则需要提高阈值。3.3 按题号分组并输出识别结果拿到所有选项框的坐标和填涂比例后需要按题目分组。常见做法是按 y 坐标排序每 N 个选项为一题N 是选项数再按 x 坐标排序确定选项顺序。但更鲁棒的做法是用聚类先按 y 坐标把选项分成若干行每行内按 x 坐标排序。def group_by_question(bubbles, options_per_question4): # 按 y 坐标排序 bubbles_sorted sorted(bubbles, keylambda b: b[1]) questions [] current_row [] last_y None for b in bubbles_sorted: if last_y is None or abs(b[1] - last_y) 20: current_row.append(b) else: # 一行结束按 x 排序后切分成题目 current_row_sorted sorted(current_row, keylambda b: b[0]) for i in range(0, len(current_row_sorted), options_per_question): questions.append(current_row_sorted[i:ioptions_per_question]) current_row [b] last_y b[1] # 处理最后一行 if current_row: current_row_sorted sorted(current_row, keylambda b: b[0]) for i in range(0, len(current_row_sorted), options_per_question): questions.append(current_row_sorted[i:ioptions_per_question]) return questions def recognize_answers(questions, threshold0.4): results [] for q in questions: # 找出填涂比例最高的选项 max_fill max(q, keylambda b: b[4]) if max_fill[4] threshold: results.append(q.index(max_fill)) else: results.append(-1) # 未填涂 return results参数说明abs(b[1] - last_y) 20中的 20 是行间距容忍度根据答题卡行高调整threshold0.4是填涂判定阈值实际使用时建议先用几张样本卡跑一遍观察填涂比例的分布再确定最佳阈值。4. 避坑与排查答题卡识别里最容易翻车的五个地方4.1 现象识别结果全错所有选项都判为未填涂原因二值化后填涂区域变成了黑色而不是白色。cv2.adaptiveThreshold的THRESH_BINARY_INV参数会把暗区变白、亮区变黑。如果答题卡是白底黑字填涂的黑块在 INV 模式下应该是白色。但如果光照太暗整张图偏黑自适应阈值可能把背景也判为白色导致填涂区域和背景混在一起。解决先检查二值化结果用cv2.imshow看一眼。如果背景大面积白色说明光照不足或阈值参数不对。可以改用THRESH_BINARY并反转逻辑或者先做直方图均衡化cv2.equalizeHist再二值化。4.2 现象定位块找到了但透视变换后答题卡是歪的原因order_points函数在极端情况下会排错角点顺序。比如答题卡旋转超过 45 度xy和x-y的极值可能对应错误的角点。解决不要只依赖order_points可以在透视变换后检查结果图像的宽高比。如果宽高比明显偏离预期比如 A4 卡应该是 1.414 左右说明角点顺序错了。此时可以尝试用cv2.minAreaRect获取旋转矩形再手动调整角点顺序。4.3 现象同一张答题卡不同手机拍出来识别率差异很大原因不同手机的摄像头畸变、白平衡、分辨率都不同。固定参数在一台设备上调好换一台就失效。解决在预处理阶段加入自适应尺寸归一化把所有图像统一缩放到固定宽度比如 600 像素高度按比例缩放。同时用cv2.equalizeHist做直方图均衡化减少光照差异。如果条件允许可以在答题卡上印定位色块用颜色校正来统一色调。4.4 现象考生涂得很轻系统判为未填涂原因填涂比例阈值设得太高。0.4 的阈值适合涂满的情况但实际考试中很多考生只涂了一半。解决不要用固定阈值改用相对阈值。对每道题取四个选项中填涂比例最高的那个如果它的比例明显高于其他选项比如高出 0.15 以上就判为已填涂。这样即使整体填涂偏轻也能正确识别。def recognize_answers_adaptive(questions, min_diff0.15): results [] for q in questions: fills [b[4] for b in q] max_fill max(fills) second_max sorted(fills)[-2] if len(fills) 1 else 0 if max_fill - second_max min_diff: results.append(fills.index(max_fill)) else: results.append(-1) return results4.5 现象答题卡边缘有阴影被误判为填涂原因自适应阈值对边缘阴影敏感阴影区域可能被二值化为白色形成假填涂。解决在提取选项框时增加位置过滤。只保留位于答题卡有效区域内部的轮廓排除边缘 10% 区域内的轮廓。另外可以在二值化前用cv2.morphologyEx做开运算去掉小噪点。def remove_edge_noise(binary, margin_ratio0.1): h, w binary.shape margin_y int(h * margin_ratio) margin_x int(w * margin_ratio) # 将边缘区域置为黑色 binary[:margin_y, :] 0 binary[-margin_y:, :] 0 binary[:, :margin_x] 0 binary[:, -margin_x:] 0 return binary5. 把识别率从 80% 推到 95% 的两个进阶技巧5.1 用模板匹配替代固定坐标切分如果你的答题卡是固定版式最稳的做法不是动态找轮廓而是先做一张标准模板图把每个选项框的坐标手动标定好存成 JSON。识别时先通过定位块把答题卡对齐到模板再按模板坐标直接切出每个选项框。这样完全避免了轮廓查找的随机性识别率能稳定在 95% 以上。import json def load_template(template_path): with open(template_path, r) as f: template json.load(f) return template def recognize_with_template(warped, template, threshold0.4): gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) binary cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 4) results [] for q in template[questions]: fills [] for opt in q[options]: x, y, w, h opt[x], opt[y], opt[w], opt[h] roi binary[y:yh, x:xw] fill_ratio cv2.countNonZero(roi) / float(w * h) fills.append(fill_ratio) max_fill max(fills) if max_fill threshold: results.append(fills.index(max_fill)) else: results.append(-1) return results模板 JSON 的结构大概是{questions: [{options: [{x: 100, y: 200, w: 30, h: 30}, ...]}, ...]}。标定模板时用一张标准答题卡手动量出每个选项框的像素坐标。虽然前期麻烦但后期识别稳定性提升明显。5.2 用填涂面积分布做二次校验即使模板匹配也可能遇到考生涂了两个选项的情况。这时候不能简单取最大值而要检测是否有多个选项的填涂比例都超过阈值。如果出现多选可以标记为异常输出给人工复核。def detect_multi_fill(fills, threshold0.4): filled [i for i, f in enumerate(fills) if f threshold] if len(filled) 1: return filled # 返回多个填涂的选项索引 return None这个函数返回多选列表如果非空说明该题需要人工干预。实际部署时可以把这些题号单独输出让老师重点检查。最后说一个我自己的习惯每次调完参数不要只看识别率要把误判的样本单独存下来按「光照问题」「填涂过轻」「多选」「定位失败」分类。跑上几十张样本后你会发现问题集中在某一两类上针对性地改那一类比盲目调阈值有效得多。这套 PythonOpenCV 答题卡识别系统源码加实验报告核心价值不在于代码多复杂而在于它把图像处理的标准流程走了一遍从预处理到特征提取再到结果输出每一步都有明确的输入输出和可调参数。如果你正在找课程设计题目或者想练手 OpenCV这个方向值得投入。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑