简介:这份资源是面向高校学生与开发者的计算机视觉实战项目包,对应毕业设计、课程设计等场景,解决答题卡自动识别与判分问题。系统以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 python=3.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, key=cv2.contourArea, reverse=True)[: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), dtype="float32") s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] # 左上,和最小 rect[2] = pts[np.argmax(s)] # 右下,和最大 diff = np.diff(pts, axis=1) 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]], dtype="float32") 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(key=lambda 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, threshold=0.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:y+h, x:x+w] # 计算非零像素占比 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", index=False, encoding="utf-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 文件里。这样换一套答题卡模板,只需要改配置文件,不用动代码。这个做法在需要支持多种答题卡格式的时候特别有用,也是我从一次次改代码改到崩溃之后学乖的。
希望这些经验能帮到你,少走一点我当年走过的弯路。
本文还有配套的精品资源,点击获取