简介这份资源是面向高校学生与开发者的计算机视觉实战项目包对应毕业设计、课程设计等场景解决答题卡自动识别与判分问题。系统以Python为开发语言采用Django搭建Web服务端借助OpenCV完成图像灰度化、二值化、边缘与轮廓检测、模板匹配及OCR识别并通过数据库存储题目、答案与学生信息实现上传答题卡、自动评分与结果查看的完整流程。压缩包共287个文件约9.15MB包含30个py源码、32个pyc编译文件、48个js脚本、26个css与12个html页面以及gif、png、jpg等图像素材和字体文件另有sql建表脚本与说明文档前端采用Bootstrap与Layui构建界面。目前已有418人学习下载。读者可据此掌握从图像预处理到Web判分的完整实现思路理解答题卡设计规范、多线程加速与错误反馈机制并可直接运行调试作为二次开发或论文写作的参考。1. 从一张手机拍的答题卡说起这套 Python 方案到底能替你省掉什么上周帮一个做教培的朋友处理月考数据他手里是三百多张用手机拍的答题卡光线忽明忽暗有的还带着桌面的阴影。他原本打算让两个助教对着答案手动录三天我花了大概一个下午用 Python 加 OpenCV 搭了一套识别加判分的流程把准确率稳定在可用范围内。这件事让我意识到基于计算机视觉的答题卡识别及判分系统并不是什么实验室里的高深课题而是一个普通开发者用一台笔记本就能落地的工程问题。它要解决的核心链路其实很清晰把一张歪斜、有噪点的答题卡照片先校正成标准视角再定位到填涂区域判断每个选项是涂了还是没涂最后对照标准答案算出分数并导出结果。适合谁做如果你会一点 Python 基础语法装过第三方库想找一个能写进简历、也能真正跑起来的计算机视觉项目这套东西的投入产出比相当高。它不像目标检测那样需要标注几千张图也不像深度学习训练那样吃显卡核心逻辑用传统图像处理就能撑住调试过程还特别直观——每一步都能把中间图存下来看翻车了也知道翻在哪。2. 答题卡识别系统的技术选型为什么我最终没上深度学习2.1 传统图像处理与深度学习的边界在哪刚接触这个方向的人第一反应往往是“计算机视觉项目是不是都得用 YOLO 或者 CNN”。我一开始也动过这个念头甚至想过用目标检测去框选项。但实际跑下来发现答题卡的场景有一个天然优势它的结构是高度标准化的。题号位置、选项排布、填涂框的形状在模板确定之后基本不变。这意味着我不需要模型去“理解”图像内容只需要用几何方法把卡片摆正再用像素统计判断填涂状态就够了。深度学习的优势在于处理语义多变、形态不固定的目标比如识别街上的行人或者区分猫和狗。但答题卡上的填涂框本质上就是一个个固定位置的小矩形用轮廓检测加透视变换就能精确定位。上深度学习反而引入了一堆新问题需要标注数据、需要训练时间、需要调参最后换来的精度提升可能只有一两个百分点还得担心过拟合。对于个人开发者或者小团队来说传统方案的可解释性和调试便利性远比那点精度重要。常见做法是先用 OpenCV 做预处理和定位把图像变成干净的、对齐的二值图然后再做填涂判断。这条路径的每一步都有明确的输入输出哪一步出问题把中间结果存成图片一看便知。我一般会建议新手先走通这条路等确实遇到传统方法搞不定的场景比如答题卡样式频繁变化、拍摄角度极端刁钻再考虑引入轻量级模型做辅助。2.2 环境搭建从 python 安装到 cv2 导入不报错动手之前先把环境理顺。热搜里 python 安装教程、vscode python 环境配置、python 下载 cv2 这些词出现频率很高说明不少人是卡在第一步的。我自己的习惯是用 conda 建一个独立环境避免和系统里的其他包打架。如果你用的是 Windows去 python 官网下载安装包时记得勾选“Add Python to PATH”这一步漏了后面在命令行里敲 python 会提示找不到命令。# 创建独立环境python 版本用 3.9 或 3.10 都比较稳 conda create -n answer_sheet python3.10 conda activate answer_sheet # 安装核心依赖opencv-python 用国内源会快很多 pip install opencv-python numpy pandas -i https://pypi.tuna.tsinghua.edu.cn/simple装完之后别急着写业务代码先跑一个最小验证确认 cv2 能正常导入并且能读图。这一步能帮你排除掉大部分环境层面的玄学问题。import cv2 import numpy as np # 读一张测试图路径换成你自己的 img cv2.imread(test_sheet.jpg) if img is None: print(图片没读到检查路径和文件名) else: print(图像尺寸:, img.shape) # 转灰度这是后续所有处理的基础 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) cv2.imwrite(gray_preview.jpg, gray)这段代码的逻辑很直白imread 负责把图片读成 numpy 数组shape 返回高度、宽度和通道数。如果返回 None八成是路径写错了或者图片格式不支持。转灰度是为了后续做二值化和轮廓检测彩色信息在答题卡识别里基本用不上。参数方面cv2.imread 默认读进来是 BGR 顺序不是 RGB这个坑后面画图的时候会碰到到时候颜色对不上别慌是通道顺序的问题。2.3 透视校正把歪着拍的答题卡摆正答题卡识别的第一个硬骨头是视角校正。手机拍出来的照片卡片往往是梯形或者旋转的直接去定位填涂框会偏得离谱。我的做法是先找到答题卡最外层的四个角点然后用透视变换把它拉成一个标准矩形。找角点的思路是转灰度、高斯模糊、边缘检测、找轮廓、按面积排序取最大的那个四边形轮廓。def find_card_corners(image_path): img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊去掉高频噪点核大小根据图片分辨率调 blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny 边缘检测两个阈值控制边缘的敏感度 edged cv2.Canny(blurred, 75, 200) # 找轮廓只取最外层 contours, _ cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue)[:5] for c in contours: # 用多边形逼近看是不是四个点 peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) 4: return approx.reshape(4, 2) return None这里有几个参数值得说清楚。GaussianBlur 的核大小 (5,5) 是个经验值图片分辨率高就加大到 (7,7)噪点多也可以加大。Canny 的两个阈值低阈值管“哪些边缘被保留”高阈值管“哪些边缘被确认”75 和 200 是我在多数手机照片上试出来比较稳的组合光线特别暗的时候可以把低阈值降到 50。approxPolyDP 里的 0.02 是逼近精度数值越小越贴近原始轮廓但太小了会把四边形逼近成更多边形导致 len(approx) 不等于 4反而找不到角点。拿到四个角点之后要确定它们的顺序不然透视变换会把图拉反。我的习惯是按左上、右上、右下、左下排好然后映射到一个固定尺寸的矩形上。def order_points(pts): # 按 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 def four_point_transform(image, 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]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (width, height)) return warpedorder_points 里用 sum 和 diff 来区分四个角是个很经典的技巧比按角度算要省事。four_point_transform 先算出目标矩形的宽高再用 getPerspectiveTransform 求变换矩阵最后 warpPerspective 完成映射。这里 width 和 height 取的是对边长度的最大值避免拉伸变形。变换后的图就是正视角的答题卡后续所有定位都在这张图上做坐标系统一省心很多。3. 填涂区域定位与判分逻辑从像素统计到分数输出3.1 用轮廓层级关系切出每道题的选项框校正之后的答题卡是一张规整的图接下来要找到每个填涂框的位置。最直接的办法是二值化之后找轮廓但整张卡上轮廓很多题号、边框、说明文字都会产生轮廓。我的做法是利用轮廓的层级关系先找到那些面积在一定范围内、形状接近矩形的小轮廓再按坐标排序映射到题号和选项上。def locate_bubbles(warped): gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # 自适应阈值应对光照不均 thresh cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 25, 10) contours, _ cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) bubbles [] for c in contours: (x, y, w, h) cv2.boundingRect(c) ar w / float(h) # 筛选条件宽高比接近 1面积在合理区间 if 0.8 ar 1.2 and 300 cv2.contourArea(c) 3000: bubbles.append((x, y, w, h)) # 按从上到下、从左到右排序 bubbles.sort(keylambda b: (b[1] // 20, b[0])) return bubblesadaptiveThreshold 用的是高斯加权blockSize 设 25 表示每个像素参考周围 25x25 的区域来算阈值C 是常数从计算结果里减去用来微调。这个组合对光照不均的图片比全局阈值稳得多。筛选条件里的宽高比 0.8 到 1.2是因为填涂框基本是正方形或者接近正方形。面积范围 300 到 3000 是根据校正后图片的分辨率估的如果你的图特别大或者特别小这个范围要跟着调。排序时用 y // 20 是为了把同一行的框归到一起避免因为几像素的偏差导致顺序错乱。3.2 判断涂没涂像素占比阈值怎么定定位到框之后判断填涂状态就简单了看框内非零像素的占比。涂了的框黑色像素多占比高没涂的框基本是白色背景占比低。但阈值定多少是个需要拿真实数据试的问题。def judge_filled(warped, bubbles, threshold0.35): gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) thresh cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] results [] for (x, y, w, h) in bubbles: roi thresh[y:yh, x:xw] # 计算非零像素占比 fill_ratio cv2.countNonZero(roi) / float(w * h) results.append(fill_ratio threshold) return resultsthreshold 设 0.35 是我在几套不同填涂工具2B 铅笔、黑色签字笔上试出来的折中值。铅笔涂得浅占比可能只有 0.3 左右签字笔涂得实能到 0.6 以上。如果你发现漏判多把阈值降到 0.25误判多升到 0.45。Otsu 自动阈值在这里比固定阈值好因为它能根据每张图的整体灰度分布自己找分割点减少光照影响。但要注意如果答题卡上有大面积阴影Otsu 可能会把阴影也当成前景这时候得先做一次背景归一化或者改用局部阈值。3.3 对照标准答案算分并导出结果判分逻辑本身没有难度就是把每道题识别出的选项和标准答案比对。但工程上要考虑的是多选题怎么处理、没涂的题怎么标记、分数怎么汇总。我的做法是给每道题定义一个标准答案字符串比如单选题是 A多选题是 ABD识别结果也拼成字符串直接比对。def grade_sheet(answers, answer_key): # answers 是每道题识别出的选项列表answer_key 是标准答案 score 0 detail [] for i, (detected, correct) in enumerate(zip(answers, answer_key)): if detected correct: score 1 detail.append((i 1, detected, correct, 正确)) else: detail.append((i 1, detected, correct, 错误)) return score, detail # 导出到 CSV方便后续统计 import pandas as pd df pd.DataFrame(detail, columns[题号, 识别结果, 标准答案, 判定]) df.to_csv(grading_result.csv, indexFalse, encodingutf-8-sig)这里用 utf-8-sig 编码是为了让 Excel 打开 CSV 时不乱码这个坑我踩过好几次。detail 列表里保留了每道题的识别结果和标准答案方便人工复核。实际用的时候我一般会把识别结果和原图上的框一起画出来存成一张标注图这样助教一眼就能看出哪道题判错了不用去翻原始数据。4. 避坑与排查那些让我熬夜的翻车现场4.1 现象角点检测偶尔找到的是桌面边缘而不是答题卡原因Canny 边缘检测对整张图做处理如果桌面纹理复杂或者有深色物体产生的轮廓面积可能比答题卡还大排序取最大轮廓时就取错了。解决在找轮廓之前先做一次颜色或者亮度过滤把明显不是答题卡的区域排除掉或者限制轮廓的宽高比答题卡一般是 A4 比例宽高比在 0.7 左右太扁或者太方的轮廓直接跳过。我后来加了一个判断要求轮廓面积占整图面积的比例在 0.3 到 0.9 之间排除了很多干扰。4.2 现象透视变换后图片被拉得特别扁或者特别长原因order_points 排错了角点顺序把左上和右上搞反了导致变换矩阵把图旋转了 90 度再拉伸。解决在 order_points 之后加一个校验算一下变换后宽高的比例如果和预期差太多就换一种排序策略重新试。更稳的办法是直接用 cv2.minAreaRect 找到最小外接矩形再根据矩形的角度决定怎么旋转这样不依赖四个角点的顺序。4.3 现象同一张卡上有的题判对有的题判错没有规律原因自适应阈值的 blockSize 设得太小导致填涂框内部出现空洞非零像素占比被拉低。解决把 blockSize 从 25 加大到 35 或者 45让阈值计算参考更大的邻域。另一个可能是填涂框定位偏了框住了一部分边框或者题号导致占比计算不准。这时候要把定位到的框画出来看一眼确认框的位置对不对。4.4 现象手机拍的照片识别率明显低于扫描件原因手机拍摄有镜头畸变、自动白平衡导致的色偏、以及手持抖动带来的运动模糊。解决在预处理阶段加一步畸变校正用 cv2.undistort 配合标定参数白平衡可以用简单的灰度世界算法做一次颜色归一化运动模糊严重的话先做一次锐化或者用维纳滤波。如果这些都不想折腾最省事的办法是让拍摄者尽量正对答题卡保持光线均匀别开闪光灯。4.5 现象多选题识别结果不稳定有时多一个选项有时少一个原因多选题的填涂框之间距离近轮廓检测时可能把两个相邻的框合并成一个或者把一个框拆成两个。解决在定位阶段加一个距离判断如果两个轮廓的中心距离小于某个阈值就认为它们是同一个框的碎片合并处理。另外多选题的判定阈值要比单选题稍微低一点因为涂多个选项时每个选项的涂写力度可能不一致。5. 把识别率再往上推一截几个我压箱底的调优习惯走到这里一套能跑的答题卡识别及判分系统已经成型了。但如果你想让它在真实场景里更稳有几个习惯值得养成。第一个是建立中间结果的可视化流水线。我每次调参之前都会把灰度图、边缘图、二值图、定位结果图、判分标注图全部存到一个 output 文件夹里按步骤编号。这样一旦某一步出问题直接翻对应的图比在代码里打断点快得多。这个习惯帮我省掉了大量“盲猜”的时间。第二个是用真实数据做阈值回归。不要凭感觉定阈值拿二三十张有代表性的答题卡人工标好每道题的填涂状态然后写个小脚本遍历不同的 threshold 值看哪个值下的准确率最高。这个脚本很简单但能让你对阈值的边界心里有数。我一般会把准确率和召回率都算出来因为漏判和误判的代价不一样有时候宁可误判也不能漏判。第三个是给系统加一个置信度输出。不要只输出“涂了”或“没涂”而是输出填涂占比的具体数值。这样在人工复核的时候占比在阈值附近的那些题会被优先检查效率高很多。我通常会把占比低于 0.2 和高于 0.5 的题标成高置信度中间地带的标成待复核助教只需要看待复核的那部分。最后一个习惯是把模板参数外置成配置文件。题号位置、选项数量、标准答案、阈值这些都不要硬编码在 Python 文件里而是写到一个 JSON 或者 YAML 文件里。这样换一套答题卡模板只需要改配置文件不用动代码。这个做法在需要支持多种答题卡格式的时候特别有用也是我从一次次改代码改到崩溃之后学乖的。希望这些经验能帮到你少走一点我当年走过的弯路。本文还有配套的精品资源点击获取
