
简介这份基于OpenCV与Python的答题卡识别项目源码包定位面向计算机相关专业的在校学生、教师及企业开发者也适合小白学习进阶覆盖从答题卡图像读取、灰度化、滤波、边缘检测、轮廓提取到填涂区域定位、答案比对与结果输出的完整识别流程可用于毕业设计、课程设计、作业演示和项目初期立项。压缩包共4个文件内置两个Python识别脚本并配有png、jpg格式的测试图片整体仅660KB结构紧凑部署方便。目前已有366人学习或下载源码均经过本地编译运行、功能验证评审分达到95分以上并获得导师和助教审定可放心下载使用。除主识别脚本外还提供另一套可对照实现的脚本配合说明文档便于理解图像处理各阶段的作用与排错思路如需改动可在此基础上扩展为不同题型或更精细的识别逻辑直接支撑课设、毕设或工程实践。1. OpenCV Python 答题卡识别是个什么项目一张翻拍图到一份成绩单的完整链路答题卡识别源码在 OpenCV Python 这个组合下几乎成了课程设计和毕业设计里最常见的“高分项目”选题。它要解决的问题很直白输入一张手机拍摄或者扫描仪扫出来的答题卡图片程序自动判断每道题涂的是 A、B、C、D 哪一项再和标准答案比对输出总分和错题号。听起来像是一个 OCR 任务但真正做过的人都知道难点根本不在“认字”而在“定位”。手机拍出来的答题卡有透视变形光线可能不均匀铅笔涂卡颜色深浅不一这些因素会让识别结果忽好忽坏。适合谁来啃这个项目正在学 OpenCV 图像处理、想用一个小而完整的案例把预处理、边缘检测、透视变换、图像分割、阈值分类串起来的人。它能让你把零散的知识点变成一条能跑通、能讲清楚、能拿去答辩的完整链路。2. 识别链路设计为什么定位选最大轮廓、分割前先拆透视、涂卡判定要看像素覆盖率2.1 版面定位为什么优先“最大轮廓 四点透视”而不是 Hough 直线检测拿到一张答题卡照片第一步不是去识别涂卡区域而是先回答一个问题答题卡在画面里的哪个位置、是什么形状。手机拍照时纸张在画面里通常是一个不规则的四边形这种几何变形叫透视变换。如果不先把这张“歪图”校正成正面视图后续所有网格切分都会错位。常见的教程里会教 Canny 边缘检测配合 Hough 直线检测找到纸张的四条边再算四条直线的交点。这个思路理论上成立实际用起来却容易翻车。答题卡本身有大量印刷表格线Canny 会同时把纸张边缘和内部表格线都检测出来Hough 直线在表格线密集区域很容易给出几十条候选线筛选哪四条才是纸张边界反而成了一个新的难题。再加一点反光或者阴影边缘断裂直线检测结果更不稳定。更稳的做法是走“最大轮廓”路线。先把图像二值化让纸张整体变成一块连通区域然后用 findContours 提取所有轮廓按轮廓面积从大到小排序。正常拍摄条件下答题卡纸张占画面最大面积所以面积最大的轮廓几乎一定是纸。拿到这个轮廓后用 approxPolyDP 把轮廓逼近成一个四边形就得到了纸张的四个顶点。这个方案对背景复杂度的容忍度比直线检测高很多只要纸张和桌面/地面有足够对比度就能工作。它的假设是“答题卡是画面里最大的物体”在课程设计这种把卡纸放在桌面正拍的场景下假设绝大多数时候成立。2.2 图像二值化OTSU 好用但浅铅笔涂卡是它的盲区定位轮廓之前图像要先变成黑白。这里存在一个隐藏设计决策用固定阈值还是用 OTSU 自适应阈值。OTSU 的原理是让前景和背景两类像素的类间方差最大它的优点是不用手动试阈值适合灰度直方图呈现明显双峰分布的图像。标准答题卡扫描件正好是这种分布——白纸一大片黑色印刷体和涂卡痕迹是另一片OTSU 一算一个准这也是很多现成源码默认用 OTSU 的原因。但 OTSU 不是万能的。手机拍照时答题卡上的铅笔痕迹往往是浅灰色的不是纯黑再加上环境光不均匀整张图的灰度直方图不会出现清晰双峰。OTSU 容易把阈值定在浅灰和白色之间导致“涂了卡但被判成没涂”。这是一个非常隐蔽的坑跑测试图可能没问题换一支 HB 铅笔、换一个角度识别率立刻下降。所以在设计识别流程时我建议把二值化当作一个“可以替换的环节”来看待而不是写死成 OTSU 就完事。后面第 4 章会给出更稳的替代办法主流程先用 OTSU 跑通理解它的边界在哪里。2.3 网格切分写死坐标 vs 自动归一化课程设计该怎么选透视校正完成之后答题卡在理想情况下是一张正面俯视图接下来要做的事情是切格——把每个选项的小方框从图像里独立抠出来逐个判断是否被涂写。这一步有两种做法。第一种是写死坐标。先量出答题卡印刷时每个格子的像素位置直接把行号、列号、格子宽高硬编码在代码里。优点是代码简单跑通速度快缺点是换一张答题卡排版、换一次打印缩放比例、换一个扫描分辨率坐标就全废了。很多从网上下载的源码包里写的正是这种硬编码方案因为它只对作者自己的那几张测试图有效。第二种是自动归一化。在透视校正后的图像上按行列等分的方式动态计算每个格子的位置。比如一张卡纸有 30 道题、每题 4 个选项那就把图像高度分成 30 份、宽度分成 4 份再在每份内部留出边界偏移得到每个选项方框的实际 ROI。这种做法不依赖具体打印参数只要答题卡版式规整就能通用。我更推荐在课程设计里用第二种因为答辩时老师大概率会问你“换一张卡纸还跑不跑得通”自动归一化比写死坐标更容易回答。这里还有一个加分项如果答题卡四角有黑色定位块可以用模板匹配或者连通域分析先把定位块找出来再根据定位块的相对位置计算网格行列。定位块的容错能力比简单等分更强即使打印时有细微偏移也能自动对齐。但如果题目没有明确要求先做“等分 内缩 margin”就足够拿分了。3. 用 OpenCV Python 把流程跑通环境、核心代码与三个可调参数3.1 环境准备Python 版本、OpenCV 安装与验证这类图像处理项目对环境要求不高保守起见建议用 Python 3.9 到 3.11 之间的版本配 opencv-python 和 numpy 就够了。OpenCV 的 Python 包名是opencv-python导入时用的是cv2这两个名字不一致是新手最常见的第一道坎。python -m pip install opencv-python numpy这里特意用python -m pip而不是直接pip是为了确保装进当前正在用的 Python 解释器里。如果你用 VSCode 开发按下CtrlShiftP打开命令面板选择Python: Select Interpreter确认右下角显示的 Python 解释器和你执行命令的是同一个。装完验证一下python -c import cv2, numpy; print(cv2.__version__)能打印出 OpenCV 版本号就说明环境通了。如果报ModuleNotFoundError: No module named cv2先不要急着重装检查解释器路径是不是选错了这是网络上的 opencv 安装教程很少提到、但实际项目里最高频的问题。注意 OpenCV 的版本差异3.x 的findContours返回三个值4.x 返回两个值。写代码时建议取通用写法cnts cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts cnts[0] if len(cnts) 2 else cnts[1]这样不管别人拿什么版本跑你的源码至少不会在函数返回值这里当场报错。3.2 第一步读图、去噪、二值化与最大轮廓定位下面这段代码完成从读图到透视校正的完整预处理。它把前面第 2 章讲的选型落实成可执行代码最大轮廓定位纸张、四点顶点排序、透视变换拉正图像。import cv2 import numpy as np def order_points(pts): # 输入的 pts 是四个轮廓顶点按“左上、右上、右下、左下”排序 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上角xy 最小 rect[2] pts[np.argmax(s)] # 右下角xy 最大 d np.diff(pts, axis1) # 每行算 y - x rect[1] pts[np.argmin(d)] # 右上角y-x 最小 rect[3] pts[np.argmax(d)] # 左下角y-x 最大 return rect def load_image(path): # imread 对中文路径支持差用 fromfile 绕过去 data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR) def preprocess_and_warp(image_path): img load_image(image_path) if img is None: raise ValueError(图片读取失败检查路径是否含中文或文件是否损坏) # 统一缩放宽度避免超大原图拖慢轮廓检测 h, w img.shape[:2] target_w 1024 if w target_w: scale target_w / w img cv2.resize(img, (target_w, int(h * scale))) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊抑制手机照片的传感器噪声 gray cv2.GaussianBlur(gray, (5, 5), 0) # OTSU 二值化黑墨迹变白白纸变黑 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 闭运算连接纸张边缘的断口防止轮廓不闭合 kernel np.ones((5, 5), np.uint8) binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 取最大轮廓作为纸张 cnts cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts cnts[0] if len(cnts) 2 else cnts[1] if not cnts: raise ValueError(没有找到任何轮廓) paper max(cnts, keycv2.contourArea) # 用多边形逼近把不规则轮廓拟合成四边形 peri cv2.arcLength(paper, True) approx cv2.approxPolyDP(paper, 0.02 * peri, True) if len(approx) ! 4: raise ValueError(检测到的纸张轮廓不是四边形检查拍摄背景是否太杂) pts approx.reshape(4, 2).astype(float32) rect order_points(pts) # 计算目标矩形尺寸保持答题卡原始宽高比 (tl, tr, br, bl) rect width_top np.linalg.norm(tr - tl) width_bottom np.linalg.norm(br - bl) height_left np.linalg.norm(bl - tl) height_right np.linalg.norm(br - tr) dst_w int(max(width_bottom, width_top)) dst_h int(max(height_left, height_right)) dst np.array([[0, 0], [dst_w - 1, 0], [dst_w - 1, dst_h - 1], [0, dst_h - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(img, M, (dst_w, dst_h)) return warped这段代码里最需要关注的地方是approxPolyDP的第二个参数0.02 * peri。peri是轮廓周长0.02 是经验比例意思是“允许轮廓点偏离拟合直线的最大距离是周长的 2%”。纸张轮廓很大2% 通常有十几个像素足够把边缘锯齿忽略掉。如果你的答题卡轮廓被反光切得很碎可以把这个比例调到 0.03逼近结果会更有弹性。order_points里面用到的排序逻辑是经典写法xy 最小的是左上xy 最大的是右下y-x 最小的是右上最大的是左下。如果这里排序写错后面透视变换出来的图像会被整体旋转或者镜像评分结果全是错的。皮肤颜色、拍摄角度都不会影响这个方法但前提是四个点确实来自同一个纸张轮廓。3.3 第二步透视校正后切格用像素覆盖率判定涂卡校正后的图像已经是一张正面俯视图接下来就是按行列等分切格。这里不关心格子里有没有印刷字母只统计每个选项 ROI 区域内的白色前景像素占比也就是“涂卡覆盖率”。覆盖率超过阈值就认为这个选项被涂了。def extract_marks(binary_warped, rows, cols, options, threshold0.3, margin(0.1, 0.15)): # rows: 题目数cols: 栏数单栏答题卡 cols1 # options: 每道题的选项个数ABCD 就是 4 h, w binary_warped.shape[:2] region_h h / rows region_w w / (cols * options) margin_x, margin_y margin answers {} for r in range(rows): marked [] for c in range(cols): for opt in range(options): x0 int((c * options opt margin_x) * region_w) y0 int((r margin_y) * region_h) x1 int((c * options opt 1 - margin_x) * region_w) y1 int((r 1 - margin_y) * region_h) roi binary_warped[y0:y1, x0:x1] rate cv2.countNonZero(roi) / ((x1 - x0) * (y1 - y0)) if rate threshold: marked.append(opt) answers[r] marked return answers这里margin_x0.1的意思是每个选项格子左右各缩 10%margin_y0.15是上下各缩 15%。为什么必须缩边因为印刷线的框线、题号、选项字母这些元素在二值化后都是白色如果统计全格区域它们会贡献不少非零像素导致“没涂也判成涂了”。缩边之后统计区域集中在方框中央那里只有铅笔痕迹会成为白色信噪比一下子高很多。threshold0.3是最常用的起点。正常涂卡覆盖率通常在 60% 以上空格的印刷干扰在缩边后通常低于 10%0.3 这个值有足够的分隔空间。但要注意阈值不是拍脑袋定的它和 margin 强相关。如果你把 margin 改到 0.2统计区域更小覆盖率会更容易超过阈值这时候 threshold 可以适当提高到 0.4。调参的基本原则是先固定 margin再扫描 threshold找一组能让“空格全判空、涂格全判涂”的值。3.4 第三步对照标准答案评分并输出错题与可视化标记拿到每道题的涂写情况后评分逻辑反而是整个项目里最简单的一块。标准答案用一个列表存起来下标对应题目序号值对应正确选项编号0 代表 A、1 代表 B、2 代表 C、3 代表 D。def grade_card(image_path, answer_key, rows30, cols1, options4): warped preprocess_and_warp(image_path) warped_gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) warped_gray cv2.GaussianBlur(warped_gray, (3, 3), 0) _, warped_bin cv2.threshold( warped_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) answers extract_marks(warped_bin, rows, cols, options) # 统计得分涂了多个选项或没涂都算错 correct, wrong 0, [] for i, ans in enumerate(answer_key): if answers.get(i) [ans]: correct 1 else: wrong.append(i 1) # 题号从 1 开始方便看错题 # 把答案画到原图上方便人工核对 for r, marks in answers.items(): for opt in marks: text f{r1}-{chr(65opt)} cv2.putText(warped, text, (10, (r 1) * 10), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 0, 255), 1) out_path image_path.replace(.jpg, _marked.jpg) cv2.imencode(.jpg, warped)[1].tofile(out_path) return { score: correct, total: len(answer_key), wrong: wrong, answers: answers, marked_image: out_path, } if __name__ __main__: key [0, 2, 1, 3, 0] # 示例标准答案按自己的答题卡改 result grade_card(test_card.jpg, key, rows5) print(result[score], /, result[total]) print(错题, result[wrong])cv2.imencode(...).tofile(...)这个写法是刻意的它解决了 OpenCV 在 Windows 下用imwrite写中文路径会失败的问题和前面读图时用imdecode是一对配套操作。评分时把“多涂”“没涂”都判错是模拟标准读卡机的行为。如果你希望空白题不算错、只扣错题的分就把answers.get(i) []的情况排除掉这个逻辑可以按自己的评分标准调整。4. 答题卡识别避坑指南五个让项目当场停摆的高频问题4.1 import cv2 报错ModuleNotFoundError 排查三件事现象在终端里执行python -m pip install opencv-python显示安装成功但导入cv2仍然报ModuleNotFoundError: No module named cv2。原因绝大多数情况不是 OpenCV 没装上而是装到了别的 Python 解释器里。VSCode 用户尤其常见右下角选中的解释器是 A 环境终端里的python却指向 B 环境两边各装各的永远对不上。另外某些一键安装的 Python 发行版会把pip指向系统目录导致权限问题和路径混乱。解决首先在项目终端执行python -c import sys; print(sys.executable)把打印出的路径和 VSCode 解释器路径对比不一致就先切换解释器。然后改用python -m pip install --upgrade opencv-python确保装进当前解释器。最后再执行第 3.1 节的验证命令。如果还报错看报错末尾的提示缺什么补什么OpenCV 的依赖一般只有 numpy不会牵涉其他复杂包。4.2 透视校正后图像还是歪的轮廓层级和顶点排序都有问题现象preprocess_and_warp跑完输出的图像确实变大了但答题卡仍然是斜的网格线和真实答题卡对不上。原因最常见的是找错了轮廓。RETR_EXTERNAL只取最外层轮廓但如果拍摄时背景里有一块比答题卡更大的物体或者答题卡和背景颜色对比较弱最大轮廓就不是纸。另一个原因是approxPolyDP拟合出的四边形顶点排序错误——四点顺序必须严格是“左上、右上、右下、左下”一旦两个点对调透视矩阵就错了。解决轮廓筛选加一道面积校验要求最大轮廓面积至少占整张图面积的 20%不满足就提示“拍摄范围太杂”。另外在order_points之后打印四个顶点坐标人工确认顺序如果发现tl的 y 坐标比tr大说明图像上下颠倒了。最稳妥的验证方式是透视校正后保存一张图肉眼检查网格是否方正这一步别省。4.3 浅涂卡误判空白固定阈值和全局 OTSU 的边界现象答题卡明明涂了 B程序输出却是空白。换一支 2B 铅笔可能正常用 HB 铅笔就失效或者同一支笔光线稍微偏一点就识别不出来。原因第 2.2 节提过全局 OTSU 在灰度直方图不呈双峰时会找错阈值。HB 铅笔的石墨反光强在图像里呈现浅灰色灰度值接近白纸OTSU 把阈值定在浅灰和白色之间铅笔痕迹就被归进了背景。缩边统计虽然避开了印刷线干扰但没解决“前景像素本身变少”的问题。解决第一步对每个 ROI 做一次膨胀操作把离散的铅笔痕迹连成片再统计覆盖率第二步把 threshold 从 0.3 降到 0.15第三步如果还不行放弃全局 OTSU改用自适应阈值cv2.adaptiveThreshold它对光照不均和浅色涂卡要友好得多。实际项目中我一般用第二种方案因为它改动最小两行代码识别率就能从 60% 拉到 95% 以上。4.4 网格整体错位打印缩放变化后写死坐标全部失效现象在自己的电脑上跑测试图全部正确换了一份扫描件或者换一台打印机打印的答题卡答案错位严重——A 答题卡识别成了 B或者整行偏移。原因答题卡印刷时存在 1 到 3 毫米的裁切误差打印机也可能按比例缩放导致格子的绝对坐标变了。如果源码里写死了(345, 567)这种像素坐标换图必翻车。解决不要依赖绝对坐标全部改成比例定位。第 3.3 节的extract_marks已经做了这件事但要注意rows、cols、options这些参数必须从答题卡版式计算出来不能写在某个魔法数字里。更稳的做法是增加定位块检测在答题卡四角设计四个黑色方块识别时先用轮廓检测找到这四个方块中心再根据它们的连线把整个版面划分成网格。这套方案能让程序在打印偏移 ±1 厘米时仍然稳定工作答辩时也更容易解释清楚。4.5 中文路径读图返回 NoneOpenCV imread 的隐藏限制现象图片放在类似D:\试卷\答题卡\test.jpg的路径下cv2.imread(...)返回None程序直接报AttributeError或者提示图片为空。原因OpenCV 在 Windows 下对非 ASCII 路径支持不完整中文路径会被系统当作乱码传给底层 API读取失败。这个问题在项目里特别容易出现因为演示电脑的用户名经常是中文。解决使用np.fromfile配合cv2.imdecode代码见第 3.2 节的load_image函数。写文件时同样要避免cv2.imwrite改用cv2.imencode加tofile。这个坑非常隐蔽报错信息不明显很多人折腾半天以为是图坏了实际上换个英文路径就一切正常。如果项目要求演示建议把输入图片统一放到英文路径下同时保留imdecode兼容写法双保险。5. 进阶验证用批处理脚本统计识别率并把阈值从“拍脑袋”改成“看分布”跑通单张图片之后不要急着交差。课程设计里最常被老师追问的问题是“你的准确率是多少”而准确率不是靠一两张图说出来的要用一批数据统计出来。from pathlib import Path def batch_grade(image_dir, answer_key, rows30): results [] for img_path in sorted(Path(image_dir).glob(*.jpg)): res grade_card(str(img_path), answer_key, rowsrows) print(f{img_path.name}: {res[score]}/{res[total]} 错题 {res[wrong]}) results.append(res) total_correct sum(r[score] for r in results) total_questions sum(r[total] for r in results) print(f整体正确率: {total_correct / total_questions:.1%})批处理的价值不只是算个准确率它还能帮你暴露出“哪些题总是错”。如果错题集中在某几行大概率不是算法问题而是网格切多了或切少了半行如果错题随机分布才需要考虑阈值和涂卡判定。有了批处理后调阈值就别再拍脑袋了。我常用的方法是写一个小脚本遍历 threshold 从 0.1 到 0.6 的取值对同一批图片分别评分找出整体正确率最高的那个区间。正确率曲线通常会出现一个平台——比如 0.2 到 0.4 都表现不错那就取中间值 0.3如果曲线尖锐到只有一个点表现好说明 margin 或二值化策略有更严重的问题不是单纯调阈值能救回来的。这个过程就是把“玄学”变成“数据”答辩时直接展示不同阈值下的正确率对比图比口头解释有说服力得多。另外一个值得做的进阶是不要只输出分数把识别结果可视化。在每道题旁边画上检测到的选项和正确性标记生成一份“阅卷结果图”。这看起来是个小功能但在演示环节作用很大——老师一眼就能看出程序找对了位置也会更愿意相信你的识别效果是真实稳定的。我自己第一次做这类项目时把所有精力都放在识别算法上结果连续两天卡在透视校正后来才发现大部分问题不是算法不够高级而是没有先想清楚“哪些参数可以被自动计算、哪些必须靠测试确认”。如果你能把这套流程完整跑一遍不仅拿到了一个能交功课的源码更重要的是理解了图像处理项目里“环境、定位、阈值”这三个环节各自占多少工作量。希望帮到你。本文还有配套的精品资源点击获取