ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OpenCV答题卡识别判卷实战:从图像预处理到自动评分

OpenCV答题卡识别判卷实战:从图像预处理到自动评分 简介基于Python与OpenCV实现的答题卡识别判卷项目面向计算机相关专业毕业设计、期末大作业及课程设计场景提供可直接运行的源代码、项目报告与答辩PPT。代码覆盖答题卡图像预处理、选项区域定位、涂写识别、学号识别及成绩判定等环节并包含Web展示页面与训练辅助脚本适合希望快速复现或在此基础上扩展的开发者。压缩包共49个文件大小约3MB核心为9个Python源码文件另有22张JPG测试图片、5个XML模型配置、5个PYC编译文件、HTML页面、PDF报告及PPTX答辩文档目录结构清晰便于按模块对照学习。当前已有136人学习下载资源难度适中、评审分较高报告与答辩PPT可直接用于梳理论文思路和准备汇报源码和样例图片则能帮助理解OpenCV在答题卡识别中的完整流程。1. 答题卡识别判卷从拍照到出分的完整链路用 OpenCV 做答题卡识别判卷是这几年毕业设计里性价比很高的一条路。它不像目标检测那样依赖大模型也不需要你精通深度学习——把轮廓检测、透视变换、阈值分割这几步传统图像处理串起来就能跑通“拍照 → 定位 → 识卡 → 判分 → 出结果”的完整链路。这套源码里Python OpenCV 主流程把选择题识别和学号识别都做了学号识别还带了一个 VGG 小模型另外配套 Flask 页面做结果展示报告和答辩 PPT 也是现成的。对于期末大作业或者毕设来说拿到手能直接改、直接跑能省掉前期大量重复造轮子的时间。2. 图像预处理与答题卡定位轮廓检测、透视变换的参数怎么设答题卡识别第一步不是认题而是把答题卡从背景里抠出来。手持拍摄的图角度歪、光线暗、背景乱预处理和定位一旦不准后面识别全是白做。answer_card_recognition.py 里光这一步就占了快一半代码不是没有原因的。2.1 灰度、去噪与边缘提取先转灰度这是 OpenCV 图像处理的标准起点。彩色图三个通道对边缘检测没有额外增益反而会把拍摄噪声放大。高斯模糊核大小我固定用 (5, 5)sigma 让 OpenCV 自己算Canny 阈值默认 50/150这两个值直接决定边缘图的干净程度。import cv2 def preprocess_image(image): # 转灰度去掉颜色信息减少后续计算量 gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 高斯模糊核太小压不住噪点太大边框边缘也一起糊掉 blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny 低阈值 50、高阈值 150按测试图调出来的经验值 edged cv2.Canny(blurred, 50, 150) return gray, blurred, edged这段代码的逻辑就是“降噪 → 边缘提取”。Canny 的 50 是低阈值150 是高阈值低于 50 的像素直接丢弃高于 150 的保留中间的弱边缘只有连接到强边缘才保留。这个机制对答题卡很友好因为卡框是连续强边缘而背景纹理是孤立弱边缘恰好被过滤掉。我拿 0.jpg 到 15.jpg 这批测试图跑过手机照片普遍偏暗Canny 低阈值降到 30 效果更好如果拍照环境灯光均匀保持 50 反而能滤掉更多桌面纹理。这里没有一个万能参数建议把阈值设计成函数参数调试时不用改代码。另外过曝严重的图比如 10.jpg 和 13.jpg高光区域会把边缘直接吃掉这种情况要先对原图做一次 CLAHE 自适应直方图均衡化把局部对比度拉回来再做边缘检测。2.2 轮廓检测面积排第一的轮廓不一定是答题卡边缘图拿到后用 cv2.findContours 找轮廓。这里有一个关键选型检索模式必须用 RETR_EXTERNAL只拿最外层轮廓。答题卡内部有大量涂卡块、题号文字的边缘如果用 RETR_LIST这些内部轮廓会全部参与排序不仅慢还可能把最大四边形误判成答题卡。def get_card_contour(edged): # RETR_EXTERNAL 只保留最外层轮廓避免内部涂卡块干扰 contours, _ cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 按面积降序取前 5 个就够答题卡在这个场景里一定是最大物体 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 return None这里有一条实战教训不能只看面积。桌面上摊开的笔记本、鼠标垫、甚至 A4 纸都可能成为最大轮廓。所以我做了两重过滤多边形逼近后顶点数必须等于 4四边形必须符合答题卡的长宽比一般宽高比在 1.3 到 1.6 之间超出这个范围直接丢掉。approxPolyDP 的 epsilon 取 0.02 倍周长是经验值。调大一点轮廓会被压成三角形调小一点四边形边缘的锯齿会保留下来影响后续透视变换的精度。另一个注意点是 OpenCV 旧版本里 cv2.findContours 返回三个值新版本返回两个值。如果报“not enough values to unpack”说明 OpenCV 版本差异的老问题又犯了改成 contours, _ 解包即可。2.3 透视变换顶点排序错了卡片就是镜像翻转找到四边形的四个顶点后不能直接算变换矩阵必须按左上、右上、右下、左下排序。排序用坐标特征判断左上角是 xy 最小的点右下角是 xy 最大的点右上角是 y-x 最大的点左下角是 y-x 最小的点。import numpy as np def four_point_transform(image, pts): rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上x y 最小 rect[2] pts[np.argmax(s)] # 右下x y 最大 diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] # 右上y - x 最大 rect[3] pts[np.argmax(diff)] # 左下y - x 最小 (tl, tr, br, bl) rect widthA np.sqrt(((br[0] - bl[0]) ** 2) ((br[1] - bl[1]) ** 2)) widthB np.sqrt(((tr[0] - tl[0]) ** 2) ((tr[1] - tl[1]) ** 2)) maxWidth max(int(widthA), int(widthB)) heightA np.sqrt(((tr[0] - br[0]) ** 2) ((tr[1] - br[1]) ** 2)) heightB np.sqrt(((tl[0] - bl[0]) ** 2) ((tl[1] - bl[1]) ** 2)) 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 warpedmaxWidth、maxHeight 取两侧距离的大值是防止透视形变下对边长度不一致导致内容被截断。getPerspectiveTransform 通过源四边形和目标矩形算出单应矩阵warpPerspective 拿矩阵做重投影。这一步做完答题卡应该是正的、填满画面的。透视变换之后建议再补一次二值化。我一般用 cv2.threshold(..., cv2.THRESH_BINARY | cv2.THRESH_OTSU)让 OTSU 自动算阈值。不过注意OTSU 是全局阈值如果答题卡一半被阴影覆盖全局阈值会失效。后面识别选项时我更多用 countNonZero 统计黑像素比例对光照变化的容忍度高很多。2.4 中间结果必须落盘没有调试图的项目等于盲修预处理阶段最容易出现“代码能跑但拉正后的卡片是歪的或镜像的”。这种问题看代码看不出必须看中间图。import os debug_dir ./debug os.makedirs(debug_dir, exist_okTrue) cv2.imwrite(os.path.join(debug_dir, 1_gray.jpg), gray) cv2.imwrite(os.path.join(debug_dir, 2_edged.jpg), edged) cv2.drawContours(gray, [approx], -1, (0, 255, 0), 2) cv2.imwrite(os.path.join(debug_dir, 3_contour.jpg), gray) cv2.imwrite(os.path.join(debug_dir, 4_warped.jpg), warped)我拿 10.jpg、13.jpg 这两张高曝光测试图排过坑Canny 边缘断成好几截轮廓根本框不全答题卡。看了中间图才确定要降 Canny 低阈值而不是改轮廓检测逻辑。图像处理项目的调试本质上就是在看中间结果——把每一步都写进磁盘比任何日志都好使。这个习惯我到现在都保留着每跑一个项目先在代码里埋好 debug 输出后面能省出大量盯着报错瞎猜的时间。3. 选项识别与学号识别涂卡判定阈值、数字分类的细节卡片拉正之后进入真正的识别环节。这一章拆两个核心模块选择题涂卡块的判定以及学号的识别。前者是 OpenCV 传统像素统计后者用到了 VGG 小模型两个思路正好形成对比。3.1 选项格子怎么切用投影法定行、定列答题卡上涂卡块是规则排列的所以第一步是把每个选项格子的 ROI 精确切出来。常见做法是先把二值化图按水平和垂直方向做投影——统计每一行、每一列的黑像素数量黑像素的波峰区域就是题目行和选项列的位置。def find_row_projections(binary): # 水平投影统计每行黑像素数 row_proj np.sum(binary 0, axis1) rows [] in_block False for i, val in enumerate(row_proj): if val 10 and not in_block: start i in_block True elif val 10 and in_block: rows.append((start, i)) in_block False return rows这个投影法比固定坐标切片可靠得多。固定坐标的问题在于透视变换后的卡片尺寸会随拍照距离变化——近拍 1200 像素宽远拍可能只有 600写死坐标基本是给自己挖坑。投影法根据内容自动找行只要卡片拉正了行位置天然是稳定的。拿到行区间后再对每个行区域内做垂直投影就能把 A、B、C、D 四列切出来。注意这里阈值 10 不是绝对的如果二值化后噪声大可以先用一次开运算把孤立噪点去掉再算投影。3.2 涂卡判定countNonZero 和动态阈值每个选项格切成固定 ROI 之后判定“有没有涂卡”的核心指标是黑像素覆盖率。涂卡块的像素值接近 0没涂的区域因为印刷底色、灰尘等总会有少量黑点。这里的关键是把阈值设在“明显低于涂卡覆盖率、又明显高于背景噪声”的区间。def is_marked(cell_binary, threshold0.3): # cell_binary 是单个选项格子的二值图黑像素为 0 total cell_binary.size black cv2.countNonZero(cell_binary) # 白色像素数量反视图 # 在这里注意如果用的是 THRESH_BINARY_INV黑像素才变成白色 # 所以 countNonZero 返回的是涂卡区域的像素数 ratio black / total return ratio threshold我这里用了一个容易踩的坑提醒threshold 参数取 0.3 是我在测试图上的经验值。涂满的格子覆盖率一般在 0.5 到 0.7 之间没涂的格子如果印刷边框明显覆盖率可能到 0.1 左右所以 0.3 是一个安全边界。但涂卡不饱满、铅笔痕迹浅的图覆盖率和印刷边框可能很接近。这时候不要死守固定阈值改成先对整个卡片算所有格子的覆盖率分布再用聚类或 OTSU 自动区分两类格子。3.3 学号识别数字分割与 VGG 小模型学号识别比选项判定复杂因为学生可能写的是手写数字而不是涂卡。这套源码里的做法是先定位学号区域然后按轮廓切分单个数字再送到 VGG 小模型做分类。def recognize_exam_num(image): # 1. 裁剪学号区域位置来自模板配置 num_roi image[y0:y1, x0:x1] # 2. 二值化 开运算去噪 _, binary cv2.threshold(num_roi, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 3. 找数字轮廓按 x 坐标排序 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) digits [] for c in contours: x, y, w, h cv2.boundingRect(c) if h 0.5 * num_roi.shape[0] and w 2: # 过滤噪点 digits.append((x, binary[y:yh, x:xw])) digits.sort(keylambda t: t[0]) # 4. 每个数字缩放后送入 VGG 分类器 results [] for _, digit in digits: resized cv2.resize(digit, (32, 32)) pred my_vgg.predict(resized) # 返回 0-9 的类别 results.append(pred) return int(.join(map(str, results)))这里每个环节都有取舍。轮廓切数字的关键是过滤条件数字的高度应该占学号区域高度的 60% 以上宽度不能太小否则会把噪点当成数字。VGG 模型训练用的是 my_dataset.py 构建的数据集train_my_model.py 负责训练预测时直接加载训练好的权重即可。如果你的场景不想用深度学习也可以退回到模板匹配——但模板匹配对字体敏感同一个数字换个写法就认不出来。VGG 小模型的好处是见过足够多手写体变体泛化能力强很多。资源包里 my_vgg.py 已经写好了网络结构直接调就行。3.4 识别结果的输出结构识别完成后需要把结果组织成结构化数据方便后面的判卷逻辑消费。这里我一般用 dict{ exam_num: 20230101, answers: {1: 1, 3: 2, ...}, # 题号: 选项编号A1, B2, C3, D4 accuracy: 0.98 # 识别置信度可以用于调试 }这个结构在 choice_question_recognition.py 里组装。判卷逻辑是纯函数式的输入标准答案和考生答案输出得分和 Web 界面完全解耦。这样设计的好处是以后想换前端框架或加数据库判卷代码一个字都不用改。4. 判卷逻辑与得分核算标准答案比对、计分规则和结果输出识别做完之后判卷本身反而简单但简单不代表可以随便写。判卷逻辑的难点不在“比对答案”这一行代码而在答案格式的标准化、分值体系的配置、以及面对异常情况时怎么保证分数不崩。4.1 标准答案的配置方式标准答案不能写死在识别代码里否则换一套题就要改源码。我一般把它放到独立配置区或者干脆读一个 JSON 文件。# 标准答案配置题号 - 选项编号A1, B2, C3, D4 STANDARD_ANSWERS { 1: 1, 2: 3, 3: 2, 4: 4, 5: 1, 6: 2, 7: 3, 8: 4, 9: 1, 10: 2, # ... 后续题号自行补齐 }用 dict 存答案的好处是查找是 O(1) 的而且天然支持稀疏结构——有些题空着不填也不会报错。如果你拿到的题目是多选题可以改成 dict 的值存 set例如 {2, 3} 表示 B 和 C。4.2 得分核算与异常处理判卷代码要处理两类异常考生漏涂的题考生多涂的题。漏涂就是这道题不在考生答案里多涂就是同一题出现了两个选项。真实答题卡上这两种情况都会出现判卷逻辑要给出明确规则。def score_paper(standard, student, score_per_question5): total 0 detail {} for q_num, correct in standard.items(): user_ans student.get(q_num, None) if user_ans is None: detail[q_num] {correct: correct, user: None, status: miss, score: 0} elif user_ans correct: detail[q_num] {correct: correct, user: user_ans, status: right, score: score_per_question} total score_per_question else: detail[q_num] {correct: correct, user: user_ans, status: wrong, score: 0} return {total: total, detail: detail}注意 detail 里我把每题的状态都存下来了这个在 Web 页面渲染成绩单时非常有用。result.html 可以逐题展示对错、标出正确选项和考生选项比只给一个总分有说服力得多。另外还有一个容易被忽略的点总分字段应该支持自定义分值体系。20 题每题 5 分是默认配置但如果试卷是 50 题每题 2 分只需要改函数参数不用动判卷逻辑。score_per_question 这个参数放在函数签名里就是为了让调用方显式传入。4.3 边缘情况一题多涂怎么处理一题多涂在实际判卷里属于“犯规”题目常见做法是直接计 0 分。但 OpenCV 识别层面可能会把两个选项都判定为涂卡这时候 student dict 里会出现 {3: {A, B}}。我在判卷逻辑里做了统一处理只要选项集合长度大于 1就视为异常卷该题计 0 并把状态标记为 invalid。elif isinstance(user_ans, (set, list)) and len(user_ans) 1: detail[q_num] {correct: correct, user: user_ans, status: invalid, score: 0}这个设计在项目报告里是一个亮点答辩时可以直接讲题目从“图像识别”上升到“异常数据治理”老师会觉得你考虑问题完整。5. 避坑与常见问题排查OpenCV 版本差异、轮廓误判、涂卡漏检这套源码在我本地编译运行是没问题的但拿到别的环境跑大概率会先碰到几个经典坑。把这些排在前面的常见问题列出来可以省掉你不少百度时间。5.1 报错 not enough values to unpack现象cv2.findContours 那行直接抛 ValueError: not enough values to unpack (expected 3, got 2)。原因OpenCV 版本差异。3.x 版的 findContours 返回 (image, contours, hierarchy) 三个值4.x 版返回 (contours, hierarchy) 两个值。解决新版写法统一用 contours, _ cv2.findContours(...)。如果项目要兼容旧版可以按 OpenCV 主版本号做分支但我更建议直接升级到 OpenCV 4.x老版本很多 API 都过时了。5.2 轮廓框选的不是答题卡而是桌面背景现象透视变换出来的图是一张笔记本封面的正面照或者是一个矩形桌垫。原因轮廓面积排第一的物体不一定是答题卡当背景里有更大的矩形物体时逻辑缺陷就会一直命中。解决从 “只看面积”改成 “面积 四边形 长宽比” 三重过滤。具体做法是用 approxPolyDP 判断顶点数再加一个宽高比的硬性约束。我还会把候选轮廓按面积降序取前 5 个逐一验证而不是只赌 top1。5.3 透视变换后卡片左右镜像、上下颠倒现象拉正后的图像是倒的或者左右反了卡上的字没法读。原因顶点排序逻辑错乱。argmin 和 argmax 的坐标特征没算对左上被放到了右下变换矩阵自然错。解决用 xy 最小值定左上、xy 最大值定右下、y-x 最大值定右上、y-x 最小值定左下四个特征值互不冲突。改完先跑一遍 2.4 的调试图用肉眼看拉正效果不要盯着终端输出猜。5.4 涂卡不饱满导致选项漏判现象铅笔涂写痕迹浅或者只见一条横线没有涂满识别结果总有几题漏判。原因固定阈值太高没涂满的格子黑像素覆盖率只有 0.25正好卡在 0.3 阈值线下面。解决先统计全部格子的覆盖率分布画出直方图看两个峰的位置再用 OTSU 或 KMeans 在两个峰之间自动取阈值。另一个补救手段是形态学膨胀把浅涂区域放大 1 到 2 个像素再算覆盖率。这个方法我建议放在二值化之后、覆盖率计算之前膨胀核选 3x3 就够。5.5 二值化后噪点被当成涂卡块现象没涂的选项里有小片黑点覆盖率超过阈值被判成已选。原因打印时墨点、铅笔灰、灰尘颗粒在二值化后都变成了黑像素没有预处理去噪。解决先做一次开运算也就是先腐蚀再膨胀把稀疏噪点消掉。代码就一行binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))开运算对答题卡场景是刚需因为涂卡块面积大腐蚀后膨胀能恢复原状而孤立噪点腐蚀后直接消失。如果噪点是大块的可以加大核到 5x5但要留意涂卡块边缘也会被磨掉一点。6. 进阶用 Flask 把识别结果做成可演示的 Web 页面源码包里带了个 app.py 和 result.html这就是一个 Flask 演示站点。为什么不直接跑命令行因为答辩和期末检查时评委不会跟你在终端里确认输出看到一个网页上传答题卡、自动批改、展示得分效果完全不一样。Flask 部分的核心就几步接收上传图片、调用识别函数、把结构化结果传给模板。代码按最短路径写别加多余装饰。from flask import Flask, request, render_template, redirect, url_for import os app Flask(__name__) UPLOAD_FOLDER ./uploads os.makedirs(UPLOAD_FOLDER, exist_okTrue) app.route(/, methods[GET, POST]) def index(): if request.method POST: f request.files[card] if f.filename : return redirect(url_for(index)) save_path os.path.join(UPLOAD_FOLDER, f.filename) f.save(save_path) result main.recognize(save_path) # 直接调用主流程 return render_template(result.html, resultresult) return render_template(hello.html) if __name__ __main__: app.run(debugTrue, host0.0.0.0, port5000)这段代码里 main.recognize 就是整个 OpenCV 识别流程的入口它应该在内部完成从加载图片到输出分数的全部工作。result.html 拿到 result 字典后渲染总分、每题对错明细和识别出的学号。如果要在本地跑通记得先把 flask 装上环境变量和启动命令按标准 Flask 项目来就行。模板渲染时有一点要注意Flask 的 render_template 默认去 templates 目录找 HTML 文件。如果报 TemplateNotFound八九不离十是 html 文件放错目录了。源码包里的 hello.html 和 result.html 是配套的一个做上传页一个做结果页。我还习惯在 result.html 里加两行调试信息——识别置信度和耗时。真正答辩时问“这个结果准不准、怎么证明”直接把耗时和置信度亮出来比空口说“准确率挺高”有力得多。回想我最初做答题卡识别项目时第一次跑通就兴奋地去测所有图片结果五张图翻车三张。后来老实了每次发版前都强制走一遍“中间结果落盘 阈值分布直方图 异常卷模拟”三件套翻车率一下就降下来了。这个习惯至今沿用。希望这份拆解帮你少走那几段弯路祝你一次跑通。本文还有配套的精品资源点击获取
返回列表