☰
Python+OpenCV答题卡识别判分系统:从拍照到Excel成绩单全流程
2026/9/29 2:42:29 网站建设 项目流程

简介:这份资源是面向高校学生与开发者的计算机视觉实战项目包,对应毕业设计、课程设计等场景,核心是用Python实现答题卡图像的自动识别与判分。项目以Django搭建Web服务端,借助OpenCV完成灰度化、二值化、边缘与轮廓检测、模板匹配等图像处理流程,并配合数据库存储题目、答案与学生信息,形成从上传答题卡到输出评分的完整闭环。压缩包共287个文件,约9.15MB,包含30个py源码、32个pyc编译文件、48个js脚本、26个css与12个html页面,以及gif、png、jpg等界面与演示素材,另有sql建表脚本和说明文档,前端采用bootstrap与layui等框架,目录结构清晰,便于按模块阅读与二次开发。目前已有418人学习下载,适合想了解计算机视觉与Web开发结合方式、需要可运行参考项目的读者,可据此掌握图像预处理、特征定位、答案匹配及Django视图与ORM的落地思路。

1. 从一张歪着拍的答题卡说起:这套 Python 计算机视觉判分系统到底解决什么问题

答题卡识别这件事,真正做过的人都知道,难点从来不是"识别 ABCD",而是老师用手机随手一拍、光线不均、纸张带折痕、四个角标还被手指挡住一角的那种图。基于 Python 的计算机视觉答题卡识别及判分系统,要干的就是把这种"脏图"变成一张干净的成绩表:先定位答题卡区域并做透视矫正,再切出每个题目的填涂框,判断哪一框被涂黑,最后对照标准答案算分并导出结果。它适合两类人:一类是学校里想给单元测验做快速批改的老师或教务,另一类是正在找计算机视觉项目练手、想跑通一条完整链路的同学——从图像预处理到几何变换再到判分逻辑,这条链路几乎把计算机视觉入门阶段该练的基本功都串了一遍。整套东西用 Python 加 OpenCV 就能落地,不需要 GPU,一台普通笔记本足够跑。

2. 答题卡识别判分系统的技术链路:从拍照到成绩单要过哪几道关

2.1 整条流水线的五个阶段

把系统拆开看,一张答题卡从进到出要经过五个阶段,每个阶段都有明确的输入输出,这样出问题时才能定位到底是哪一环翻车。

第一阶段是图像获取与预处理。输入是手机或扫描仪给的原始图,输出是灰度化、去噪、二值化之后的干净图。这一步的目标不是"好看",而是让后续的边缘检测能稳定找到答题卡的外轮廓。常见做法是先转灰度,再用高斯模糊压掉噪点,最后用自适应阈值或者 Canny 拿到边缘。

第二阶段是答题卡定位与透视矫正。输入是二值边缘图,输出是"正对着你"的矩形答题卡。核心是找轮廓、筛出面积最大的四边形、取四个角点,然后用透视变换把它拉正。这一步是整个系统里最容易出玄学的地方,角点找偏一点,后面所有题目的坐标全跟着偏。

第三阶段是填涂区域切分。答题卡拉正之后,题目位置就相对固定了。你可以按比例硬切,也可以先检测定位黑块再按行列推算。硬切简单但要求模板固定,检测定位灵活但代码复杂,选哪个取决于你的答题卡是不是统一印刷的。

第四阶段是填涂判定。对每个选项框算黑色像素占比,超过阈值就算涂了。听起来简单,但阈值怎么定、多涂怎么处理、擦除痕迹怎么算,全是细节。

第五阶段是判分与输出。把识别结果和标准答案比对,算总分,导出成 Excel 或者 CSV。这一步纯逻辑,没有视觉成分,但格式设计不好,老师用起来照样骂人。

2.2 为什么选 OpenCV 而不是深度学习方案

很多人一上来就想上 YOLO 或者 CNN 做端到端识别,觉得这样"高级"。但对于答题卡这种结构化极强的场景,传统计算机视觉方案反而更稳、更快、更好调。

原因有三。第一,答题卡的几何结构是已知的,四个角标、固定的行列布局,这些先验信息用传统方法能直接吃进去,而深度学习方案反而要把这些先验"学"出来,浪费算力。第二,传统方案不需要标注数据,你不需要标几千张答题卡才能跑起来,改个阈值就能适配新模板。第三,可解释性强,识别错了你能一步步看是哪一环节出的问题,而不是面对一个黑匣子干瞪眼。

当然,如果你的场景是"任意角度、任意光照、任意模板"的极端情况,那深度学习确实有优势。但绝大多数校内测验场景,答题卡是统一印刷的,传统方案足够用,而且部署成本低得多。

2.3 环境准备与最小可运行骨架

先把环境搭起来。Python 版本建议 3.8 以上,核心依赖就三个:OpenCV、NumPy、openpyxl(导出 Excel 用)。安装命令如下:

pip install opencv-python numpy openpyxl

如果你用 PyCharm 或者 VS Code,直接在项目里建虚拟环境再装,避免污染全局。装完之后跑一句import cv2; print(cv2.__version__)确认没问题。

下面是一个最小骨架,把整条流水线的函数签名先立起来,后面几章逐个填实现:

import cv2 import numpy as np def preprocess(image_path): """读图并做灰度、模糊、边缘检测""" img = cv2.imread(image_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) edged = cv2.Canny(blurred, 75, 200) return img, edged def find_card_contour(edged): """找面积最大的四边形轮廓""" contours, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours = sorted(contours, key=cv2.contourArea, reverse=True) for c in contours[:5]: peri = cv2.arcLength(c, True) approx = cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) == 4: return approx return None def four_point_transform(image, pts): """透视变换,把答题卡拉正""" rect = order_points(pts.reshape(4, 2)) (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) return cv2.warpPerspective(image, M, (width, height)) def order_points(pts): """把四个角点排成左上、右上、右下、左下""" 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

这段代码里几个参数值得说清楚。GaussianBlur的核大小(5, 5)是经验值,图越大可以适当加大到(7, 7),但必须是奇数。Canny的两个阈值75和200是低阈值和高阈值,低阈值管"弱边缘要不要保留",高阈值管"强边缘从哪开始",光照差的时候把低阈值降到 50 左右能救回一些断掉的边。approxPolyDP里的0.02 * peri是逼近精度,值越小越贴近原始轮廓,但太小会导致本该是四边形的轮廓被逼近成五六个点,一般 0.02 到 0.05 之间调。

order_points这个函数是透视变换的关键。它用坐标和找左上(和最小)和右下(和最大),用坐标差找右上(差最小)和左下(差最大)。这个逻辑对正放的答题卡没问题,但如果答题卡旋转超过 45 度,就可能排错,后面避坑章节会讲怎么处理。

3. 填涂区域切分与判分逻辑:坐标怎么定、阈值怎么调

3.1 按模板比例切分 vs 按定位块动态切分

答题卡拉正之后,接下来要把每个题目的选项框切出来。这里有两条路。

第一条是按模板比例硬切。假设你的答题卡是 A4 大小,拉正后图像宽高固定,那么第 1 题 A 选项的框位置就是固定的像素坐标。你只需要在代码里写死一组坐标,或者写一个比例表,按图像实际尺寸换算。优点是简单、快、不需要额外检测;缺点是换一种答题卡模板就得改坐标,而且对透视矫正的精度要求高,矫正偏几个像素,切出来的框就可能压线。

第二条是按定位块动态切分。答题卡上通常有黑色的定位方块或者定位线,先检测这些定位块的位置,再根据它们推算每个题目框的坐标。优点是适应性强,轻微形变也能扛;缺点是多了一层检测逻辑,定位块本身没检测到就全盘崩。

我一般会这么做:如果答题卡是学校统一印刷、模板固定的,直接用比例切分,代码简单可控;如果是多种模板混用,或者扫描质量参差不齐,就上定位块方案。下面给的是比例切分的实现,因为它是大多数项目的起点。

def split_bubbles(warped, rows, cols, start_x, start_y, gap_x, gap_y, box_size): """ 按固定行列切分选项框 rows: 题目行数 cols: 每题选项数(一般 4) start_x, start_y: 第一个框左上角坐标 gap_x, gap_y: 框之间的水平和垂直间距 box_size: 每个框的边长 """ boxes = [] for r in range(rows): row_boxes = [] for c in range(cols): x = start_x + c * gap_x y = start_y + r * gap_y box = warped[y:y + box_size, x:x + box_size] row_boxes.append(box) boxes.append(row_boxes) return boxes

参数说明:start_x和start_y需要你在拉正后的图上量一次,量准了后面就固定。gap_x是同一题相邻选项框的间距,gap_y是相邻题目之间的行距。box_size是框的边长,一般取比实际填涂框略小一点,避免把框线本身算进去。这几个参数建议做成配置文件或者常量,方便换模板时改。

3.2 填涂判定的阈值怎么定才不飘

切出框之后,判定逻辑就是算黑色像素占比。核心代码:

def is_filled(box, threshold=0.35): """判断一个选项框是否被涂黑""" gray = cv2.cvtColor(box, cv2.COLOR_BGR2GRAY) if len(box.shape) == 3 else box _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) black_ratio = np.sum(binary == 255) / binary.size return black_ratio > threshold, black_ratio

这里用了 OTSU 自动阈值做二值化,比固定阈值稳。THRESH_BINARY_INV是把黑色变成 255、白色变成 0,这样统计 255 的数量就是黑色像素数。threshold=0.35是判定阈值,意思是黑色占比超过 35% 就算涂了。

这个 0.35 不是拍脑袋来的。正常涂满的框黑色占比在 0.6 到 0.8 之间,完全没涂的在 0.05 以下,半涂或者擦除痕迹在 0.2 到 0.4 之间。取 0.35 是为了把"涂了一半"也算作有效填涂,同时排除掉框线本身和噪点。如果你的答题卡框线比较粗,或者扫描分辨率低导致框内噪点多,这个值要往上调到 0.4 甚至 0.45。

实际项目里我建议不要只用一个全局阈值,而是对每个框算完占比后,先收集所有框的占比分布,看看有没有明显的双峰。如果有,取谷底作为阈值;如果没有明显双峰,说明图像质量有问题,得回头查预处理。

3.3 多涂、漏涂和擦除痕迹的处理

真实答题卡上什么情况都有。一个题涂了两个选项,这是多涂;一个都没涂,这是漏涂;涂了又擦,留下一片灰,这是擦除痕迹。这三种情况判定逻辑要分开处理。

多涂的处理:如果一个题有超过一个框的占比超过阈值,标记为"多涂",判分时算错,同时在结果里标注出来让老师人工复核。不要自作聪明取占比最高的那个,因为学生可能就是涂了两个。

漏涂的处理:如果一个题所有框的占比都低于阈值,标记为"漏涂",同样算错并标注。

擦除痕迹的处理:擦除痕迹的占比通常在 0.2 到 0.35 之间,正好卡在阈值边缘。这时候可以加一个"灰区"判断,占比在 0.2 到 0.35 之间的框标记为"疑似",输出时高亮,让老师决定。这个灰区范围要根据你的实际图像调,不是固定的。

def judge_question(boxes, high=0.35, low=0.20): """返回该题的判定结果和状态""" ratios = [is_filled(b)[1] for b in boxes] filled = [i for i, r in enumerate(ratios) if r > high] suspect = [i for i, r in enumerate(ratios) if low < r <= high] if len(filled) == 1: return filled[0], "normal" elif len(filled) == 0: return None, "blank" if not suspect else "suspect" else: return None, "multi"

这段逻辑把每道题分成 normal、blank、suspect、multi 四种状态,判分时只有 normal 才参与对错计算,其余都算错但保留状态供复核。这样老师拿到结果能一眼看出哪些是机器不确定的,而不是被一个错误的自动判分误导。

4. 避坑与排查:那些让识别率从 95% 掉到 60% 的细节

4.1 现象:透视矫正后答题卡是歪的,切出来的框全部压线

原因:order_points用坐标和、坐标差排序,在答题卡旋转角度接近 45 度时会排错,导致透视变换的四个目标点对错位置,拉正后图像被翻转或拉伸。

解决:不要只依赖坐标和排序。更稳的做法是先算四个点的质心,然后按极角排序,再根据角度关系确定左上、右上、右下、左下。或者加一个校验:变换后图像的宽高比如果和预期差太多,就换一种排序方式重试。

4.2 现象:同一张答题卡,白天拍识别正常,晚上开灯拍就大面积漏检

原因:Canny 的固定阈值对光照敏感。晚上灯光偏黄、对比度低,边缘检测出来的轮廓断断续续,findContours找不到完整的四边形。

解决:把 Canny 换成自适应阈值,或者在做 Canny 之前先做一次直方图均衡化。更省事的办法是用cv2.adaptiveThreshold替代 Canny 做二值化,它对局部光照变化更鲁棒。如果坚持用 Canny,把低阈值从 75 降到 50,高阈值从 200 降到 150,能救回大部分暗光图。

4.3 现象:填涂判定把框线当成了填涂,所有题都判成多涂

原因:切分时box_size取大了,把选项框的黑色边框也切了进去。边框的黑色像素占比可能就有 0.3 以上,加上框内如果有一点填涂,直接超过阈值。

解决:两个办法。一是把box_size缩小,只取框内部区域,一般比实际框小 20% 到 30%。二是在判定前先做一次形态学腐蚀,把细线条去掉。我一般两个一起用,先缩框再腐蚀,基本能解决。

4.4 现象:程序跑起来很慢,一张图要十几秒

原因:findContours在整张高分辨率图上跑,轮廓数量巨大;或者透视变换后的图没有缩放,后续切分和判定都在大图上做。

解决:读图之后先缩放到固定宽度,比如 1000 像素,再做后续所有处理。答题卡识别不需要原始分辨率,1000 像素宽足够切出准确的框。另外findContours之前先做一次形态学闭运算,把碎轮廓合并,能减少轮廓数量。这两步做完,一张图通常能压到 1 秒以内。

4.5 现象:换了一种答题卡模板,所有坐标全错

原因:比例切分的坐标是写死的,换模板等于换布局,坐标自然对不上。

解决:把坐标参数抽出来做成配置字典,每种模板一个配置。更彻底的做法是上定位块方案,让程序自己找定位块再推算坐标。如果只是偶尔换模板,配置字典就够了;如果模板经常变,值得花时间做定位块检测。

5. 把判分结果导出成老师能直接用的 Excel,以及一个提高鲁棒性的小技巧

判分逻辑跑通之后,最后一步是输出。老师不会去看你的控制台打印,他们要的是能直接打开、能排序、能打印的表格。用 openpyxl 导出 Excel 是最实用的选择。

from openpyxl import Workbook def export_result(results, answer_key, output_path): """ results: 每题的识别结果列表,元素为 (题号, 选项索引, 状态) answer_key: 标准答案列表,元素为选项索引 """ wb = Workbook() ws = wb.active ws.title = "判分结果" ws.append(["题号", "识别选项", "标准答案", "状态", "得分"]) total = 0 for i, (q_num, choice, status) in enumerate(results): correct = answer_key[i] if status == "normal" and choice == correct: score = 1 total += 1 else: score = 0 choice_str = chr(65 + choice) if choice is not None else "-" answer_str = chr(65 + correct) ws.append([q_num, choice_str, answer_str, status, score]) ws.append(["", "", "", "总分", total]) wb.save(output_path) return total

这段代码把每题的结果、标准答案、状态和得分写进表格,最后一行汇总总分。status列很关键,老师可以按这一列筛选出所有非 normal 的题目,快速定位需要人工复核的地方。选项索引用chr(65 + choice)转成 A、B、C、D,比直接输出数字友好得多。

导出之后建议再加一步:把原始答题卡图、矫正后的图、以及标注了识别结果的图一起存下来。这样老师如果对某道题有疑问,能直接翻图核对,而不是只能相信表格里的数字。这个"后悔药"在项目刚上线、识别率还没调稳的时候特别有用。

最后说一个提高鲁棒性的小技巧:不要只跑一次识别就出结果,而是对同一张图跑两次,一次用原始参数,一次用稍微放宽的阈值,然后对比两次结果。如果两次结果一致,说明这道题识别很稳;如果不一致,标记为"低置信度",让老师重点看。这个双跑策略几乎不增加多少计算量,但能把误判率压下去一截。我自己做过的几个版本里,加了这一步之后,需要人工复核的题目比例从 15% 降到了 5% 左右,老师的使用意愿明显不一样。

这套系统从拍照到出成绩单,核心链路就是预处理、透视矫正、切分、判定、导出五步,每一步都有可以调的参数和可以踩的坑。先把最小链路跑通,再针对你自己的答题卡模板和拍摄条件去调阈值和坐标,比一上来就追求"全自动高精度"要靠谱得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询