目录

核心思路

分步实现与代码解析

1. 环境准备与工具函数定义

2. 图片预处理

3. 轮廓提取与筛选

3. 轮廓提取与筛选

4. 透视变换(矫正倾斜答题卡)

5. 阈值处理(突出填涂区域)

6. 提取选项圆圈轮廓

7. 选项轮廓排序(按题目顺序排列)


在日常教学与考试场景中,人工批改答题卡不仅耗时耗力,还容易因主观疲劳导致误判。本篇将基于 OpenCV 实现全自动答题卡识别与改分,通过图像处理技术精准提取答案区域、比对标准答案,并自动计算得分,大幅提升批改效率与准确性。

核心思路

答题卡识别改分的核心是 “从图像中精准定位有效信息并与标准对比”,整体流程分为 9 个关键步骤:

  1. 图片预处理(去噪、增强)
  2. 边缘检测(突出答题卡轮廓)
  3. 轮廓提取与筛选(定位答题卡主体)
  4. 轮廓近似(确定答题卡四角,为透视变换做准备)
  5. 透视变换(将倾斜答题卡矫正为正视图)
  6. 阈值处理(将图像转为 “非黑即白”,突出填涂区域)
  7. 选项圆圈轮廓提取(定位每道题的 5 个选项)
  8. 答案比对(识别填涂选项,与标准答案匹配)
  9. 分数计算(统计正确率,生成最终得分)

分步实现与代码解析

项目答题卡如下:

1. 环境准备与工具函数定义

首先导入所需库,并定义图像显示函数(方便中间结果查看):

import cv2
import numpy as np# 图像显示函数:接收窗口名和图像,按任意键关闭窗口
def cv_show(name, img):cv2.imshow(name, img)cv2.waitKey(0)  # 等待按键输入(0表示无限等待)cv2.destroyWindow(name)  # 关闭当前窗口

2. 图片预处理

原始图像可能存在噪声、色彩干扰,预处理的目标是 “简化图像信息,突出关键边缘”,步骤包括:

  • 灰度化:将彩色图像转为单通道灰度图,减少计算量
  • 高斯滤波:通过平滑处理去除高频噪声(如纸张纹理、拍摄噪点)
  • 边缘检测:用 Canny 算法提取图像边缘,为后续轮廓定位做准备
"""-----1. 图片预处理-----"""
# 读取答题卡图像(替换为你的图像路径)
image = cv2.imread(r'./images/answer_sheet_01.jpg')
contours_img = image.copy()  # 备份原始图像,用于后续绘制轮廓# 1.1 灰度化
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)  # BGR转灰度(OpenCV默认读取格式为BGR)
# 1.2 高斯滤波(5x5卷积核,标准差0,平衡去噪与边缘保留)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
cv_show('Gaussian Blur (去噪后)', blurred)  # 查看去噪效果
# 1.3 Canny边缘检测(阈值1=75,阈值2=200,仅保留对比度高的边缘)
edged = cv2.Canny(blurred, 75, 200)
cv_show('Canny Edges (边缘检测结果)', edged)  # 查看边缘检测效果

3. 轮廓提取与筛选

边缘检测后,需要从边缘图中提取闭合轮廓,并筛选出 “答题卡主体轮廓”(通常为矩形,即 4 个顶点):

"""-----2. 轮廓提取与筛选-----"""
# 2.1 提取轮廓(RETR_EXTERNAL:仅保留最外层轮廓;CHAIN_APPROX_SIMPLE:简化轮廓点)
# OpenCV 3.x返回值为 (_, cnts, _),OpenCV 4.x返回值为 (cnts, _),此处兼容3.x
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1]
# 2.2 绘制所有轮廓(红色,线宽3),查看轮廓提取效果
cv2.drawContours(contours_img, cnts, -1, (0, 0, 255), 3)
cv_show('All Contours (所有轮廓)', contours_img)# 2.3 筛选答题卡轮廓(按面积降序排序,优先保留大面积轮廓)
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
doc_cnt = None  # 存储答题卡的最终轮廓for c in cnts:# 计算轮廓周长(True表示轮廓闭合)peri = cv2.arcLength(c, True)# 轮廓近似(0.02*peri:近似精度,值越小越接近原始轮廓)approx = cv2.approxPolyDP(c, 0.02 * peri, True)# 答题卡为矩形,近似后轮廓应包含4个顶点if len(approx) == 4:doc_cnt = approxbreak# 绘制筛选出的答题卡轮廓(绿色,线宽2)
cv2.drawContours(image, [doc_cnt], -1, (0, 255, 0), 2)
cv_show('Answer Sheet Contour (答题卡轮廓)', image)

轮廓入戏:

3. 轮廓提取与筛选

边缘检测后,需要从边缘图中提取闭合轮廓,并筛选出 “答题卡主体轮廓”(通常为矩形,即 4 个顶点):

"""-----2. 轮廓提取与筛选-----"""
# 2.1 提取轮廓(RETR_EXTERNAL:仅保留最外层轮廓;CHAIN_APPROX_SIMPLE:简化轮廓点)
# OpenCV 3.x返回值为 (_, cnts, _),OpenCV 4.x返回值为 (cnts, _),此处兼容3.x
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1]
# 2.2 绘制所有轮廓(红色,线宽3),查看轮廓提取效果
cv2.drawContours(contours_img, cnts, -1, (0, 0, 255), 3)
cv_show('All Contours (所有轮廓)', contours_img)# 2.3 筛选答题卡轮廓(按面积降序排序,优先保留大面积轮廓)
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
doc_cnt = None  # 存储答题卡的最终轮廓for c in cnts:# 计算轮廓周长(True表示轮廓闭合)peri = cv2.arcLength(c, True)# 轮廓近似(0.02*peri:近似精度,值越小越接近原始轮廓)approx = cv2.approxPolyDP(c, 0.02 * peri, True)# 答题卡为矩形,近似后轮廓应包含4个顶点if len(approx) == 4:doc_cnt = approxbreak# 绘制筛选出的答题卡轮廓(绿色,线宽2)
cv2.drawContours(image, [doc_cnt], -1, (0, 255, 0), 2)
cv_show('Answer Sheet Contour (答题卡轮廓)', image)

4. 透视变换(矫正倾斜答题卡)

实际拍摄的答题卡可能存在倾斜,透视变换可将 “倾斜的矩形” 转为 “正对着镜头的矩形”,方便后续选项定位:

  • 第一步:定义order_points函数,将 4 个顶点按 “左上→右上→右下→左下” 排序
  • 第二步:定义four_point_transform函数,计算透视变换矩阵并应用变换
"""-----3. 透视变换(矫正答题卡)-----"""
def order_points(pts):"""将4个顶点按“左上(tl)→右上(tr)→右下(br)→左下(bl)”排序"""rect = np.zeros((4, 2), dtype="float32")  # 初始化排序后的坐标# 1. 左上点:x+y最小;右下点:x+y最大s = pts.sum(axis=1)  # 每个点的x+y求和rect[0] = pts[np.argmin(s)]  # 左上(tl)rect[2] = pts[np.argmax(s)]  # 右下(br)# 2. 右上点:x-y最小;左下点:x-y最大diff = np.diff(pts, axis=1)  # 每个点的x-y差值(axis=1:按行计算后一列减前一列)rect[1] = pts[np.argmin(diff)]  # 右上(tr)rect[3] = pts[np.argmax(diff)]  # 左下(bl)return rectdef four_point_transform(image, pts):"""透视变换:将倾斜的答题卡转为正视图"""# 步骤1:获取排序后的4个顶点rect = order_points(pts)tl, tr, br, bl = rect  # 解包顶点坐标# 步骤2:计算目标图像的宽度和高度(取最大值确保覆盖完整答题卡)# 宽度:右下→左下 的水平距离 / 右上→左上 的水平距离width_a = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))width_b = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))max_width = max(int(width_a), int(width_b))  # 目标宽度# 高度:右上→右下 的垂直距离 / 左上→左下 的垂直距离height_a = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2))height_b = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))max_height = max(int(height_a), int(height_b))  # 目标高度# 步骤3:定义目标图像的4个顶点(正视图的四个角)dst = np.array([[0, 0],                  # 左上[max_width - 1, 0],      # 右上(-1是因为像素索引从0开始)[max_width - 1, max_height - 1],  # 右下[0, max_height - 1]      # 左下], dtype="float32")# 步骤4:计算透视变换矩阵M,应用变换得到正视图M = cv2.getPerspectiveTransform(rect, dst)  # 生成3x3变换矩阵warped = cv2.warpPerspective(image, M, (max_width, max_height))  # 执行变换return warped# 执行透视变换(doc_cnt是答题卡的4个顶点,需转为float32格式)
warped = four_point_transform(image, doc_cnt.reshape(4, 2))
cv_show('Warped Sheet (矫正后答题卡)', warped)

5. 阈值处理(突出填涂区域)

矫正后的答题卡仍为灰度图,通过 “阈值二值化” 将图像转为 “非黑即白”,让填涂的选项(深色)与空白选项(白色)对比更强烈:

"""-----4. 阈值处理(突出填涂区域)-----"""
# 5.1 将矫正后的彩色图转为灰度图
warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
# 5.2 二值化(THRESH_BINARY_INV:黑白反转;THRESH_OTSU:自动计算最佳阈值)
# 效果:填涂区域为白色(255),空白区域为黑色(0)
thresh = cv2.threshold(warped_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
cv_show('Thresholded (二值化结果)', thresh)# 备份二值化图像,用于后续绘制选项轮廓
thresh_copy = thresh.copy()

6. 提取选项圆圈轮廓

答题卡每道题包含 5 个圆形选项,需从二值化图中提取这些圆圈轮廓,并筛选出 “符合选项大小” 的轮廓:

"""-----5. 提取选项圆圈轮廓-----"""
# 6.1 提取二值化图中的所有轮廓(仅保留最外层轮廓)
option_cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1]
# 6.2 绘制所有轮廓(绿色,线宽1),查看选项定位效果
warped_with_options = cv2.drawContours(warped.copy(), option_cnts, -1, (0, 255, 0), 1)
cv_show('All Option Contours (所有选项轮廓)', warped_with_options)# 6.3 筛选“有效选项轮廓”(排除过小/过扁的干扰轮廓)
valid_option_cnts = []
for c in option_cnts:# 获取轮廓的边界矩形(x:左上角x坐标,y:左上角y坐标,w:宽度,h:高度)x, y, w, h = cv2.boundingRect(c)# 计算宽高比(圆形的宽高比接近1)aspect_ratio = w / float(h)# 筛选条件:宽度≥20px、高度≥20px、宽高比0.9~1.1(接近圆形)if w >= 20 and h >= 20 and aspect_ratio >= 0.9 and aspect_ratio <= 1.1:valid_option_cnts.append(c)# 绘制筛选后的有效选项轮廓(红色,线宽1)
warped_valid_options = cv2.drawContours(warped.copy(), valid_option_cnts, -1, (0, 0, 255), 1)
cv_show('Valid Option Contours (有效选项轮廓)', warped_valid_options)

               

7. 选项轮廓排序(按题目顺序排列)

提取的选项轮廓可能杂乱无章,需按 “从上到下、从左到右” 排序,确保与题目顺序对应:

"""-----6. 选项轮廓排序(按题目顺序)-----"""
def sort_contours(cnts, method='left-to-right'):"""按指定方向排序轮廓:left-to-right(左右)、top-to-bottom(上下)"""reverse = False  # 是否反向排序axis = 0         # 排序依据的轴(0:x轴,1:y轴)# 1. 确定排序方向和轴if method in ['right-to-left', 'bottom-to-top']:reverse = True  # 反向排序(从右到左/从下到上)if method in ['top-to-bottom', 'bottom-to-top']:axis = 1  # 按y轴排序(上下方向)# 2. 为每个轮廓创建“边界矩形”,按矩形的x/y轴坐标排序bounding_boxes = [cv2.boundingRect(c) for c in cnts]  # 每个轮廓的边界矩形# 按“边界矩形的指定轴”排序(zip:将轮廓与矩形绑定;sorted:按轴排序)cnts, bounding_boxes = zip(*sorted(zip(cnts, bounding_boxes),key=lambda b: b[1][axis],  # 排序键:矩形的axis轴坐标(x或y)reverse=reverse))return cnts, bounding_boxes# 7.1 先按“从上到下”排序(每道题的5个选项为一组)
sorted_option_cnts, _ = sort_contours(valid_option_cnts, method='top-to-bottom')
# 7.2 绘制排序后的轮廓(蓝色,线宽1)
warped_sorted_options = cv2.drawContours(warped.copy(), sorted_option_cnts, -1, (255, 0, 0), 1)
cv_show('Sorted Option Contours (排序后选项轮廓)', warped_sorted_options)

8. 识别填涂答案与标准答案比对
核心逻辑:通过 “掩膜 + 像素计数” 识别每道题的填涂选项,再与标准答案对比,标记对错:
• 掩膜(mask):为每个选项创建 “仅包含该选项的黑白图”
• 像素计数:填涂选项的白色像素(255)数量远多于空白选项,以此定位填涂位置

"""-----7. 答案识别与比对-----"""
# 8.1 定义标准答案(键:题序号0~4,值:正确选项序号0~4,对应每道题的5个选项)
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1}
correct_count = 0  # 正确题数
warped_result = warped.copy()  # 用于绘制结果的图像# 8.2 按“每5个选项一组”遍历(每道题对应5个选项)
for (question_idx, i) in enumerate(np.arange(0, len(sorted_option_cnts), 5)):# 取当前题的5个选项轮廓,按“从左到右”排序current_question_cnts, _ = sort_contours(sorted_option_cnts[i:i+5], method='left-to-right')bubbled_idx = None  # 存储当前题的填涂选项序号(0~4)# 遍历当前题的5个选项,找到填涂的选项for (option_idx, c) in enumerate(current_question_cnts):# 步骤1:为当前选项创建掩膜(仅该选项区域为白色,其余为黑色)mask = np.zeros(thresh.shape, dtype="uint8")cv2.drawContours(mask, [c], -1, 255, -1)  # -1表示填充轮廓内部# cv_show(f'Option {option_idx} Mask (选项{option_idx}掩膜)', mask)# 步骤2:掩膜与二值化图做“与运算”,仅保留当前选项的填涂区域masked_thresh = cv2.bitwise_and(thresh, thresh, mask=mask)# cv_show(f'Masked Thresh (选项{option_idx}与运算结果)', masked_thresh)# 步骤3:计算白色像素数量(填涂区域的像素数)white_pixel_count = cv2.countNonZero(masked_thresh)# 步骤4:确定填涂选项(白色像素最多的选项即为填涂选项)if bubbled_idx is None or white_pixel_count > bubbled_idx[0]:bubbled_idx = (white_pixel_count, option_idx)  # (像素数, 选项序号)# 8.3 与标准答案比对,标记对错correct_option_idx = ANSWER_KEY[question_idx]  # 当前题的正确选项序号if bubbled_idx[1] == correct_option_idx:# 正确:绿色轮廓(线宽2),正确题数+1color = (0, 255, 0)correct_count += 1else:# 错误:红色轮廓(线宽2)color = (0, 0, 255)# 绘制当前题的正确选项轮廓(标记对错)cv2.drawContours(warped_result, [current_question_cnts[correct_option_idx]], -1,

最终结果如下:

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/diannao/99206.shtml
繁体地址,请注明出处:http://hk.pswp.cn/diannao/99206.shtml
英文地址,请注明出处:http://en.pswp.cn/diannao/99206.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Python爬虫实战:研究Pandas,构建新浪网股票数据采集和分析系统

1. 系统概述 股票数据分析系统旨在通过自动化手段获取市场数据,进行深度分析,辅助投资决策。本系统主要包含以下核心模块: 数据爬取模块:从新浪财经获取股票列表、基本信息及历史交易数据 数据处理模块:清洗原始数据,处理缺失值与异常值,计算技术指标 分析可视化模块:…

【C++STL】list的详细用法和底层实现

&#x1f31f;个人主页&#xff1a;第七序章 &#x1f308;专栏系列&#xff1a;C&#xff0b;&#xff0b; 目录 ❄️前言&#xff1a; &#x1f308;一&#xff1a;介绍 &#x1f308;二&#xff1a;list的创建 ☀️基本框架 &#x1f319;节点类 &#x1f319;构造函…

AI大模型开发(多模态+提示词)

接着之前的例子&#xff0c;继续测试模型对话&#xff0c;今天主要测试多模态加上系统提示词。 一.多模态 多模态方法&#xff0c;主要添加了对图片的测试。 public String chatWithMessage(UserMessage userMessage){ChatResponse chatResponse qwenChatModel.chat(userMess…

Qt程序单独运行报错问题

Qt程序单独运行报错问题介绍问题原因分析解决方案&#xff08;从最佳实践到临时方法&#xff09;方法一&#xff1a;使用 windeployqt 工具&#xff08;最推荐、最规范&#xff09;方法二&#xff1a;临时修改系统 PATH&#xff08;适合开发调试&#xff09;方法三&#xff1a;…

Flask学习笔记(二)--路由和变量

一、路由Flask支持两种路由1、使用route()装饰器将URL绑定到函数app.route(/hello)def hello_world():return hello world2、使用应用程序对象的add_url_rule()函数def hello_world():return hello worldapp.add_url_rule(/, hello, hello_world)二、变量规则Flask开发中&#…

Skywalking告警配置+简易邮件告警应用配置(保姆级)

Skywalking告警配置简易邮件告警应用配置前言&#xff1a; 前文&#xff1a;SkyWalking Elasticsearch8 容器化部署指南&#xff1a;国内镜像加速与生产级调优_skywalkinges-CSDN博客 ​ SKywalking Agent配置Oracle监控插件安装指南-CSDN博客 Skywalking版本&#xff1a;V10.…

无人机如何实现图传:从原理到实战的全景解读

无人机图传的工作不是简单地把镜头的数据直接“丢”到一个屏幕上&#xff0c;而是一个由编码、传输、解码三段组成的系统。首先是视频编码&#xff1a;摄像头采集的原始画面通常需要经过编解码器压缩&#xff0c;常见标准包括H.264、H.265和VP9等。压缩的目的是减少数据量&…

AS32S601在轨重构(OTA)方案的优化与分析

摘要在轨重构&#xff08;OTA&#xff09;技术因其在航天、工业控制、物联网等领域的高可靠性和持续服务需求而备受关注。本文以国科安芯推出的AS32S601芯片为研究对象&#xff0c;深入分析其OTA方案的设计原理、技术细节及优化策略&#xff0c;并结合芯片的硬件特性&#xff0…

修复Android studio的adb无法连接手机问题

复制下面的内容到一个文本txt里面然后把里面的Android studio路径和sdk路径改成你自己的&#xff0c;然后改成把.txt改成bat 右键管理员运行 echo off REM Deep Fix for "Couldnt terminate the existing process" error REM This script will completely reset ADB …

css优化都有哪些优化方案

CSS 优化其实可以分成几个层面&#xff1a;性能优化、可维护性优化、兼容性优化以及用户体验优化。这里我帮你梳理一份比较系统的 CSS 优化方案清单&#xff0c;方便你参考&#xff1a;&#x1f539; 一、加载性能优化减少 CSS 文件体积压缩 CSS&#xff08;去掉空格、换行、注…

vue,uniapp 实现卷帘对比效果

需求&#xff1a;两张图重叠放在一起&#xff0c;拖动分割线实现卷帘对比效果&#xff0c;如图一、vue2代码 <template><div class"main"><div class"img-comparison" mousedown"startSlide"><img class"before"…

【笔记】空气弹簧概述、刚度调节原理

参考链接&#xff1a;汽车底盘空气悬架关键零部件之空气弹簧 1.概述 汽车空气弹簧&#xff08;Air Spring&#xff09;是一种以“压缩空气”作为弹性介质的悬架元件&#xff0c;用来取代传统钢制螺旋弹簧或钢板弹簧。它在乘用车、客车、重卡及轨道交通上越来越普及&#xff0…

UDP Socket 进阶:从 Echo 到字典服务器,学会 “解耦” 网络与业务

开篇&#xff1a;从 “回显” 到 “字典”&#xff0c;核心变在哪&#xff1f;上一篇我们实现了 Echo 服务器 —— 网络层和业务层是 “绑死” 的&#xff1a;网络层收到数据后&#xff0c;直接把原数据发回去。但实际开发中&#xff0c;业务逻辑会复杂得多&#xff08;比如查字…

数据结构之复杂度

数据结构的理解 数据本身是杂乱无章的&#xff0c;需要结构进行增删查改等操作更好的管理数据&#xff1b; 比如&#xff1a;在程序中需要将大量的代码&#xff08;数据&#xff09;通过结构进行管理&#xff1b; 再比如&#xff1a;定义1000个整型变量的数组&#xff0c;我们…

运维安全06 - 服务安全

云计算服务安全 在当今数字化时代&#xff0c;各种服务&#xff08;如网络应用、云计算平台、数据库系统等&#xff09;已成为我们日常生活和工作中不可或缺的一部分。 然而&#xff0c;随着服务的广泛应用&#xff0c;其安全性问题也日益凸显。 一、服务安全 服务安全是一…

01数据结构-初探动态规划

01数据结构-初探动态规划前言1.基本思想2.重叠子问题3.斐波那契数列4.备忘录&#xff08;记忆化搜索表&#xff09;4.1备忘录&#xff08;记忆化搜索表&#xff09;代码实现5.DP table5.1DP table代码实现6.练习前言 在学习动态规划时切忌望文生义&#xff0c;因为其名字与其思…

[智能算法]可微的神经网络搜索算法-FBNet

一、概述 相较于基于强化学习的NAS&#xff0c;可微NAS能直接使用梯度下降更新模型结构超参数&#xff0c;其中较为有名的算法就是DARTS&#xff0c;其具体做法如下。 首先&#xff0c;用户需要定义一些候选模块&#xff0c;这些模块内部结构可以互不相同&#xff08;如设置不同…

Elasticsearch安装启动常见问题全解析

文章目录&#x1f4da; Elasticsearch 安装与启动问题总结一、核心问题概览二、详细问题分析与解决方案1. &#x1f510; **权限问题&#xff1a;AccessDeniedException**❌ 错误日志&#xff1a;&#x1f4cc; 原因&#xff1a;✅ 解决方案&#xff1a;2. ⚙️ **配置冲突&…

Uniapp中使用renderjs实现OpenLayers+天地图的展示与操作

Uniapp中自带的地图组件对支持的地图服务略有局限&#xff0c;同时&#xff0c;该组件在样式布局上层级过高且无法控制&#xff0c;无法满足部分高度自定义化的需求。故引入renderjs视图层工具搭配OpenLayers框架对地图功能进行实现&#xff0c;但由于renderjs的限制&#xff0…

从C++开始的编程生活(8)——内部类、匿名对象、对象拷贝时的编译器优化和内存管理

前言 本系列文章承接C语言的学习&#xff0c;需要有C语言的基础才能学会哦~ 第8篇主要讲的是有关于C的内部类、匿名对象、对象拷贝时的编译器优化和内存管理。 C才起步&#xff0c;都很简单&#xff01;&#xff01; 目录 前言 内部类 性质 匿名对象 性质 ※对象拷贝时的…