资讯动态

基于OpenCV的答题卡识别判分系统:从歪斜照片到成绩单的Python实战

发布时间:2026/9/28 13:19:48 来源:尧图企业网站定制
简介本资源为基于计算机视觉的答题卡识别及判分系统完整源码面向计算机、软件工程等专业的学生及需要完成毕业设计或课程设计的学习者帮助解决人工阅卷效率低、主观误差大的问题。系统采用Python与OpenCV实现答题卡图像预处理、选择题选项识别、答案比对与自动评分并将成绩写入Excel同时结合MySQL与Navicat完成数据管理整体采用模块化设计便于二次开发与维护。压缩包共293个文件包含30个py源码、32个pyc编译文件、48个js脚本、26个css样式及29个png图片等涵盖前端页面、后端逻辑与图像处理模块包体约15.36MB。目前已有91人学习下载。读者可获得一套可直接运行的完整项目源码并配套LW文档、SQL建表脚本与PPT资料便于快速理解系统架构、掌握OpenCV开发流程并在此基础上完成功能扩展与论文撰写。1. 答题卡识别判分系统从一张歪斜的手机照片到成绩单考场里收上来一摞答题卡手机随手一拍角度歪了、光线不匀、边缘还有手指阴影——这套基于计算机视觉的答题卡识别及判分系统要解决的就是把这堆废片变成可用的分数。它属于计算机视觉在文档分析方向的典型落地先做图像预处理把答题卡摆正再定位填涂区域判断每个选项是否被涂黑最后对照标准答案算分。整套流程用 Python 实现依赖 OpenCV 做图像处理、NumPy 做矩阵运算不需要深度学习模型也能跑通对毕业设计来说算力门槛低、可解释性强。适合谁正在做计算机视觉大作业或毕业设计的学生、想入门 OpenCV 实战的开发者、以及需要快速搭一个客观题批改原型的老师。下面按图像怎么进来、怎么摆正、怎么判涂、怎么算分、坑在哪的顺序讲透。2. 图像预处理把歪斜的答题卡摆正答题卡识别的第一步不是识别是矫正。手机拍出来的答题卡几乎不可能是正对镜头的透视变形会让原本矩形的填涂框变成梯形直接按固定坐标去裁切必然错位。所以整条流水线的第一段是把任意角度、任意光照的原始照片转换成一张标准尺寸、正视角的答题卡图像。2.1 灰度化、高斯模糊与边缘检测的先后顺序很多人一上来就找轮廓结果在彩色图上做 Canny 会得到一堆噪点边缘。正确顺序是灰度化 → 高斯模糊 → 边缘检测。灰度化把三通道压成单通道减少计算量高斯模糊抹掉纸张纹理和细小噪点避免它们被当成边缘Canny 再在干净的灰度图上提取轮廓。这三步的顺序不能乱模糊必须在边缘检测之前否则模糊会把真实边缘也一起抹掉。import cv2 import numpy as np def preprocess(image_path): # 读取原图 img cv2.imread(image_path) # 缩放到固定宽度减少后续计算量同时保持长宽比 ratio img.shape[1] / 800.0 dim (800, int(img.shape[0] / ratio)) img cv2.resize(img, dim, interpolationcv2.INTER_AREA) # 灰度化三通道转单通道 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊5x5 卷积核抹掉纸张纹理噪点 blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny 边缘检测双阈值低于 50 丢弃高于 150 保留 edged cv2.Canny(blurred, 50, 150) return img, edged逻辑说明先 resize 到宽度 800 是为了统一后续所有坐标参数否则换一张分辨率不同的照片填涂框的像素坐标全要重算。高斯核用 5×5 是经验值太大比如 15×15会把答题卡边框也模糊掉太小3×3去噪不干净。Canny 的双阈值 50/150 是 OpenCV 官方示例的常用组合实际拍摄环境偏暗时可以降到 30/100偏亮时提到 75/200。参数说明cv2.resize的INTER_AREA适合缩小图像比默认的INTER_LINEAR更少产生摩尔纹。cv2.GaussianBlur的第三个参数 sigma 传 0 表示由核大小自动推算一般不用手动改。2.2 用轮廓面积筛出答题卡四边形并做透视变换边缘图里会有很多轮廓桌面的边、手指的轮廓、答题卡本身的边框。答题卡的特征是面积最大的近似四边形所以策略是遍历所有轮廓按面积降序排列取前几个里能用approxPolyDP逼近成四个点的那个。def find_card_contour(edged): # 只取外轮廓RETR_EXTERNAL 避免嵌套轮廓干扰 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) # 0.02 是逼近精度系数越小越贴近原始轮廓 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 # 计算变换后宽度取上下两边较长的那个 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逻辑说明approxPolyDP的第二个参数是精度系数乘以周长得到实际容差。0.02 意味着允许轮廓偏离直线 2% 周长太小0.01会把圆角边框逼近成五个点以上太大0.05会把不规则四边形也当成答题卡。order_points是一个辅助函数按坐标和把四个点分成左上和最小、右下和最大再按差值分右上、左下这个函数必须自己实现OpenCV 没有内置。参数说明cv2.findContours在 OpenCV 3 返回三个值OpenCV 4 返回两个值网上很多老代码会报too many values to unpack就是版本差异导致的。warpPerspective的输出尺寸用maxWidth和maxHeight保证变换后不裁掉内容。3. 填涂区域定位从矫正图里切出每个选项框矫正后的答题卡是一张标准矩形图但每个考场的答题卡版式不同选项框的位置不能写死。常见做法是先定位答题卡上的定位标记通常是四个角上的黑色方块或边框再按比例划分行和列把每个填涂框的坐标算出来。如果答题卡是固定版式比如学校统一印刷也可以直接按像素坐标硬编码但换一张版式就废了所以更稳的是按比例定位。3.1 二值化与形态学操作分离填涂框矫正后的图还是灰度图需要二值化把涂黑和未涂分开。这里有个关键点答题卡上的印刷文字和填涂的黑块都是深色二值化后都会变成白色前景所以不能直接数白点要先通过形态学操作把细小的文字去掉只留下面积较大的填涂块。def binarize_and_clean(warped): # 转灰度 gray cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # 自适应阈值应对光照不均blockSize 必须是奇数 thresh cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 25, 10) # 开运算先腐蚀后膨胀去掉细小文字保留大块填涂 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) opened cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) return opened逻辑说明THRESH_BINARY_INV把深色变成白色前景方便后续统计白点数量。ADAPTIVE_THRESH_GAUSSIAN_C比全局阈值更适合手机拍摄的光照不均场景blockSize25表示每个像素参考周围 25×25 区域算阈值C10是常数偏移值越大越容易把浅灰判成背景。开运算的核用 5×5能去掉笔画宽度小于 5 像素的文字但保留填涂块通常 15 像素以上。参数说明blockSize必须大于 1 且为奇数太小如 11会在填涂块内部产生空洞太大如 51会失去自适应意义。如果答题卡印刷质量差、文字很粗开运算核要加大到 7×7。3.2 按行列比例切分选项框并统计涂黑像素定位到答题卡的有效区域后按题目数量等分。比如 20 道单选题、每题 4 个选项就把有效区域横向切成 20 行、纵向切成 4 列每个格子就是一个选项框。然后统计每个格子里的白色像素占比超过阈值就判定为已涂。def grade_answers(opened, num_questions, num_options, answer_key): h, w opened.shape # 假设有效区域从图像 5% 处开始到 95% 结束 top, bottom int(h * 0.05), int(h * 0.95) left, right int(w * 0.05), int(w * 0.95) row_h (bottom - top) // num_questions col_w (right - left) // num_options results [] for q in range(num_questions): filled [] for opt in range(num_options): y1 top q * row_h y2 y1 row_h x1 left opt * col_w x2 x1 col_w # 统计该格子内白色像素数量 cell opened[y1:y2, x1:x2] white_ratio cv2.countNonZero(cell) / (cell.shape[0] * cell.shape[1]) filled.append(white_ratio) # 白色占比最高的选项且必须超过 0.3 才算有效填涂 max_ratio max(filled) if max_ratio 0.3: results.append(-1) # 未作答 else: results.append(filled.index(max_ratio)) # 对照标准答案算分 score sum(1 for i, r in enumerate(results) if r answer_key[i]) return results, score逻辑说明white_ratio是格子内白色像素占格子总面积的比例。一个正常涂黑的选项这个值通常在 0.4 到 0.8 之间未涂的选项在 0.05 以下。阈值 0.3 是分界线低于它认为没涂。如果一道题有多个选项都超过 0.3说明考生涂了多个可以判为错或标记异常这里简单取最大值。参数说明top/bottom/left/right的 5% 边距是为了避开答题卡边框和定位标记实际版式不同要调整。row_h和col_w用整除余数丢弃所以有效区域要尽量对齐。如果答题卡有题号列横向切分前要先把题号列裁掉否则第一列会切到题号。4. 判分逻辑与结果输出从选项序列到成绩单识别出每道题的填涂选项后判分本身很简单——和标准答案逐题比对。但工程上要考虑的是怎么处理多涂、漏涂、以及输出一份可读的成绩单。这部分不复杂但细节决定这套系统能不能直接交给老师用。4.1 标准答案的存储格式与比对策略标准答案不要硬编码在代码里用 JSON 或 CSV 存方便换卷子。格式建议是{1: A, 2: C, ...}题号做 key选项字母做 value。识别结果也转成字母比对时统一大小写。import json def load_answer_key(path): with open(path, r, encodingutf-8) as f: return json.load(f) def compare(results, answer_key): # 选项索引 0-3 映射到 A-D letters [A, B, C, D] detail [] correct 0 for i, r in enumerate(results): q_num str(i 1) student_ans letters[r] if r 0 else 未作答 correct_ans answer_key.get(q_num, ) is_correct (student_ans correct_ans) if is_correct: correct 1 detail.append({ 题号: q_num, 考生答案: student_ans, 正确答案: correct_ans, 是否正确: is_correct }) return correct, detail逻辑说明results里 -1 表示未作答转成未作答字符串比对时自然为 False。answer_key.get用 get 而不是直接索引防止标准答案缺题号时抛异常。detail列表可以直接喂给 pandas 导出 Excel或者逐行打印。参数说明如果是不定项选择选项字母要拼接如 AB比对逻辑改成集合相等。这里按单选处理是毕业设计最常见的场景。4.2 用 OpenCV 在原图上标注对错并导出结果光有分数不够直观把识别结果画回原图正确画绿框、错误画红框老师一眼就能看到哪题出问题。同时把详细结果导出成 CSV方便存档。import csv def draw_and_export(warped, detail, output_imgresult.jpg, output_csvscore.csv): # 在矫正图上逐题标注 h, w warped.shape[:2] num_q len(detail) row_h int(h * 0.9) // num_q for i, item in enumerate(detail): y int(h * 0.05) i * row_h row_h // 2 color (0, 255, 0) if item[是否正确] else (0, 0, 255) cv2.putText(warped, f{item[题号]}:{item[考生答案]}, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) cv2.imwrite(output_img, warped) # 导出 CSV with open(output_csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[题号, 考生答案, 正确答案, 是否正确]) writer.writeheader() writer.writerows(detail)逻辑说明cv2.putText的坐标是文字左下角所以 y 要加半个行高居中。颜色 BGR 顺序绿色是 (0,255,0)红色是 (0,0,255)。CSV 用utf-8-sig编码Excel 打开中文不乱码这是血泪经验用普通 utf-8 会显示乱码。参数说明FONT_HERSHEY_SIMPLEX是 OpenCV 内置字体0.5是缩放比例图小就调小。lineType默认 8 邻域抗锯齿不用改。5. 避坑与排查那些让识别率从 95% 掉到 60% 的细节这套系统跑通不难难的是在不同拍摄条件下稳定。下面 5 个坑是我实际调试时踩过的每个都按现象 → 原因 → 解决写清楚。5.1 现象换一张照片就找不到答题卡轮廓原因Canny 双阈值是写死的原图偏暗时边缘太少偏亮时边缘太多approxPolyDP都逼近不出四个点。解决不要写死阈值用图像灰度中位数动态算。median np.median(blurred)然后lower int(max(0, 0.66 * median))upper int(min(255, 1.33 * median))这是 OpenCV 官方推荐的自动 Canny 参数。5.2 现象透视变换后答题卡是正的但填涂框整体偏移原因order_points函数写错了四个顶点顺序不对导致变换矩阵把图像翻转或旋转了 90 度。解决检查order_points里按坐标和排序的逻辑确保左上角是 xy 最小的点右下角是 xy 最大的点。如果答题卡是横向的宽高判断也要跟着改。5.3 现象涂得浅的选项识别不出来涂得重的选项旁边格子也判为已涂原因二值化的blockSize和C参数不合适或者开运算核太大把相邻填涂块连在一起。解决先单独可视化二值化结果看填涂块是否完整、文字是否被去掉。blockSize从 25 开始试C从 10 开始每次调 5。开运算核不要超过 7×7否则相邻选项框会被合并。5.4 现象同一张答题卡不同题目识别结果不稳定原因手机拍摄有透视变形矫正后图像上下部分的实际像素密度不同固定阈值在顶部合适、在底部就偏了。解决不要用全局阈值判填涂改用每个格子内部的相对比较——取该题四个选项里白色占比最高的且要求它比第二名高出一定比例比如 1.5 倍否则判为异常。这样即使整体偏暗相对关系不变。5.5 现象CSV 导出后中文乱码或者分数算出来是 0原因CSV 编码问题或者标准答案的题号是整数而识别结果是字符串比对时类型不匹配。解决CSV 统一用utf-8-sig。标准答案加载后key 统一转成字符串比对前也把题号转字符串避免1 ! 1这种低级错误。6. 进阶技巧用模板匹配替代比例切分让版式适配更省心前面第 3 章用的是按比例切分优点是简单缺点是换一种答题卡版式就要重新调top/bottom/left/right和行列数。如果想让系统更通用可以用模板匹配先手工裁一张标准答题卡的空白图作为模板识别时用cv2.matchTemplate在矫正后的图上找每个选项框的位置。这样只要模板和实际答题卡是同一版式不管图像怎么缩放都能定位到。具体做法是把模板里每个选项框的坐标记下来识别时对模板和待识别图做同样的预处理然后用cv2.matchTemplate配合cv2.TM_CCOEFF_NORMED找匹配区域取匹配度最高的位置作为该选项框的实际坐标。匹配度低于 0.7 就认为定位失败回退到比例切分。def locate_by_template(warped, template, template_boxes): # 模板和待识别图都转灰度 gray_t cv2.cvtColor(template, cv2.COLOR_BGR2GRAY) gray_w cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) # 在待识别图上滑动模板找最佳匹配位置 res cv2.matchTemplate(gray_w, gray_t, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc cv2.minMaxLoc(res) if max_val 0.7: return None # 匹配失败回退比例切分 # 模板左上角在待识别图中的偏移 offset_x, offset_y max_loc # 把模板里记录的选项框坐标平移到实际图上 actual_boxes [] for (x, y, w, h) in template_boxes: actual_boxes.append((x offset_x, y offset_y, w, h)) return actual_boxes逻辑说明TM_CCOEFF_NORMED返回 -1 到 1 的匹配度1 表示完全一致。minMaxLoc取最大值位置。template_boxes是事先在模板图上手工标注的选项框列表格式(x, y, w, h)。匹配成功后所有框的坐标加上偏移量就是实际位置。参数说明模板图要和待识别图分辨率接近差太多匹配度会掉。如果答题卡有旋转模板匹配会失效所以模板匹配适合已经矫正过、只剩缩放差异的场景不能替代透视变换。我自己的习惯是先用比例切分跑通全流程等识别率稳定在 90% 以上再考虑上模板匹配。因为模板匹配多了一个维护模板的成本版式一改模板就要重做而比例切分改几个参数就行。毕业设计里比例切分足够用模板匹配可以作为进阶功能写在论文里体现你对通用性的思考。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑