ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于OpenCV与Django的答题卡识别判分系统开发实战

基于OpenCV与Django的答题卡识别判分系统开发实战 简介这套基于Python与Django的计算机视觉答题卡识别及判分系统是一份适合毕业设计、课程设计及Web开发学习者参考的完整工程。项目整合了图像预处理、特征提取、文字识别与自动评分流程并配有前端交互、后端逻辑及MySQL数据库让使用者能端到端理解“上传答题卡→识别答案→输出分数”的完整实现。压缩包共294个文件约22.52MB涵盖Python源码、Django模板、CSS/JavaScript静态资源、PNG/JPG图片素材、SQL数据库脚本以及docx说明文档和PPT演示文稿目录结构清晰便于按模块查阅。内容预览中的Bootstrap、Layui等前端样式库说明界面部分采用了常见快速开发框架。目前已有57人学习下载对于希望快速搭建同类识别项目或研究Django集成图像处理方案的学习者这份资源能提供可运行的代码基础与文档支撑。1. 从一张图片到一份成绩单答题卡识别判分系统到底在解什么题很多第一次接触这类项目的同学以为难点在 Django 前后端实际上一跑起来就发现前端页面几个小时就能搭完真正耗时间的反而是摄像头或扫描仪里那张歪斜、反光、被铅笔涂得深浅不一的答题卡。这个项目标题里同时出现 Python、Django、计算机视觉、MySQL意味着它不是单纯调一个 OpenCV 的 demo而是要把图像识别结果落到一个可查询、可统计、可多人使用的 Web 系统里。换句话说这是一条从像素到数据库的完整链路OpenCV 负责“看见”答题卡上的涂写Django 负责“接住”识别结果并提供页面MySQL 负责把每次考试、每道题、每个学生的记录沉淀下来。适合刚做完 Django 基础、又想用视觉项目补齐工程能力的人也适合需要交计算机视觉大作业或课设的读者参考整体分层方法。2. 答题卡视觉识别从图片预处理到答案格坐标提取图像识别部分是整个系统里最容易被低估的环节。一张手机拍摄的答题卡进入系统后不会主动摆正、不会把答案格边界画好它可能歪斜、有阴影、背景里混着桌面纹理甚至四个角被手指挡住。所以这里要先解决“图片里的答题卡在哪”和“答案格在答题卡的什么位置”两个问题才能进入判分环节。2.1 为什么要先做透视矫正而不是直接找圆答题卡的典型特征是有一个矩形边框四角附近有定位黑块。手机拍摄或扫描仪进纸时图像大概率不是正对镜头直接按固定坐标裁剪图片在 A4、16K 等不同版式上都会失败。我一般会先用颜色过滤或灰度阈值把答题卡主体从背景里分离再用轮廓检测找出最外层矩形最后用 getPerspectiveTransform 做矫正。这样后续所有答案格的相对坐标都基于矫正后的标准图而不是原图绝对坐标。import cv2 import numpy as np def detect_card(image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) edged cv2.Canny(blurred, 75, 200) contours, _ cv2.findContours( edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) card_contour None for cnt in sorted(contours, keycv2.contourArea, reverseTrue)[:5]: peri cv2.arcLength(cnt, True) approx cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) 4: card_contour approx break return card_contour, gray这里先把图像转灰度并高斯模糊Canny 边缘检测会保留答题卡外侧的边缘。findContours 返回所有闭合轮廓按面积排序后取前五个再用 approxPolyDP 把轮廓拟合成顶点更少的多边形只有四个顶点的才可能是答题卡。0.02 是判断轮廓逼近程度的经验值边缘不连续时可以调到 0.03值越大拟合出的顶点越少。注意 RETR_EXTERNAL 只取最外层轮廓如果背景里还有其他矩形物体面积排序能过滤掉大部分干扰。得到四个顶点以后还需要固定顶点的顺序。findContours 返回的是图像坐标系下的点顺序可能是左上、左下、右下、右上如果没有统一顺序后面做透视变换时就会把图旋转或翻转。常见做法是按左上、右上、右下、左下排列然后计算变换矩阵def order_points(pts): rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] diff np.diff(pts, axis1) 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_bottom np.linalg.norm(br - bl) width_top np.linalg.norm(tr - tl) height_right np.linalg.norm(tr - br) height_left np.linalg.norm(tl - bl) max_width max(int(width_top), int(width_bottom)) max_height max(int(height_left), int(height_right)) dst np.array([ [0, 0], [max_width - 1, 0], [max_width - 1, max_height - 1], [0, max_height - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(image, M, (max_width, max_height))order_points 基于坐标和的极值判断左上、右下再基于差值判断右上、左下这套逻辑在答题卡这类规则矩形上足够稳定。four_point_transform 把原图中的任意四边形映射成一个正的矩形warpPerspective 之后整个答题卡区域对齐到画面正中。到这里后续答案格的坐标就只和标准模板有关与输入图像的拍摄角度解耦了。2.2 划分答案格用横竖线投影替代手写坐标定位到答题卡后下一步是把每道题的 A B C D 区域切出来。对于印刷规整的答题卡一般会在每道题下预先画好小方框比较稳定的方案是二值化后做水平和垂直投影找出行列间隔。_, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) horizontal binary.sum(axis1) vertical binary.sum(axis0) # 找白色像素连续出现区间的边界 row_peaks [] in_block False for i, val in enumerate(horizontal): if val 0 and not in_block: row_peaks.append(i) in_block True elif val 0: in_block False这里 Otsu 阈值自动计算亮暗分界比固定阈值更适合光照不均的图片。THRESH_BINARY_INV 会把深色的印刷框变成白色背景变黑。sum(axis1) 统计每一行有多少白色像素行与行之间的空隙对应白色像素为 0 的波段从而还原出每道题的纵向位置。实际项目中我不会直接用连续 0 判断而是计算列方向的投影直方图再用滑动窗口过滤掉噪点否则铅笔印或污点会打乱间隔。另一种更省事的方案是先手工标定一张“标准答案卡”模板记录每个答案格的圆心坐标运行时只做透视矫正再用固定坐标去答案格中心采样。两种做法对比如下方案开发量鲁棒性适用场景投影自动分隔高需处理粘连和噪点好版式变化也能适应多套答题卡、卡片不是严格印刷模板固定坐标低直接存 JSON 或数据库差答题卡模板不能变单套卡、考试场景固定如果你做的答辩演示只针对一张模板我会建议先用模板坐标如果想让系统在验收时能换第二张模板投影分隔会让代码看起来更像“计算机视觉”。2.3 涂写状态判定覆盖率与连通域计数每个答案格已经是一个 ROI 小图怎么判断它有没有被涂满最简单的做法是计算答案格内非白色像素的比例。但铅笔灰度往往介于背景和印刷框之间直接用非黑即白二值化会不稳定。我一般先统计 ROI 内像素值小于某个阈值的数量再除以 ROI 面积得到覆盖率。def is_filled(cell, threshold0.2): gray cv2.cvtColor(cell, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 120, 255, cv2.THRESH_BINARY_INV) ratio cv2.countNonZero(binary) / float(gray.size) return ratio thresholdthreshold 参数一般在 0.15 到 0.3 之间。比值设得过高会把浅涂误判为未填设得过低会把答案格边框或噪点算成涂写。对于答题卡中的 A/B/C/D 方框0.2 是个不错的起点。更严谨的做法是配合形态学开运算先去掉孤立噪点再用连通域计数过滤掉面积小于 30 像素的小块避免橡皮擦残留造成误判。这一层处理的质量直接决定后面判分系统拿到的是干净选项还是带杂讯的脏数据。3. Django 前后端与 MySQL把识别结果变成可提交的成绩Django 在这个系统中的角色不只是提供 API而是承担了表单上传、session 管理、模板渲染和数据库读写。用 Django 模板写 HTML 页面意味着服务端渲染的前端页面不需要单独部署 node后端把识别结果直接塞进 context 返回给浏览器即可。“完整前后端”在课设里通常就是这么落地不需要 Vue 或 ReactDjango 的 template 语法足够支撑题目配置、成绩查看和错误标注页面。3.1 先设计 MySQL 表结构再写 model答题卡判分系统的核心数据不是一张“成绩表”而是要能追溯“某一个学生、某一次考试、某一题、选了哪个选项、得分多少”。如果只存总分后面想展示错题分布就无从下手。我会最少建四张表考试表、学生表、答题记录表、题目表。用 Django ORM 表达如下from django.db import models class Exam(models.Model): name models.CharField(max_length100) answer_key models.JSONField(defaultlist) # [A,B,C,...] created_at models.DateTimeField(auto_now_addTrue) class Student(models.Model): student_no models.CharField(max_length20, uniqueTrue) name models.CharField(max_length50) class Question(models.Model): exam models.ForeignKey(Exam, on_deletemodels.CASCADE) question_no models.PositiveIntegerField() answer models.CharField(max_length10) score models.FloatField(default1.0) class AnswerRecord(models.Model): student models.ForeignKey(Student, on_deletemodels.CASCADE) exam models.ForeignKey(Exam, on_deletemodels.CASCADE) question_no models.PositiveIntegerField() selected models.CharField(max_length10, blankTrue) is_correct models.BooleanField(nullTrue)answer_key 用 JSONField 存标准答案在 Python 里就是 listMySQL 8 对应 JSON 类型如果 MySQL 版本较老或希望查询更直观可以改用 CharField 加逗号分隔。Question 表看起来和 answer_key 重复但它能存每道题的分值方便做不同题型权重。AnswerRecord 是判分明细is_correct 留空表示这道题还没判比如学生涂了两个选项时系统可以标记为待复核。对应迁移命令python manage.py makemigrations python manage.py migratemakemigrations 会根据 model 生成迁移文件migrate 再把它同步到 MySQL。如果连接 MySQL 时报错提示 mysqlclient 找不到先确认系统装没装 libmysqlclient-dev或者用 mysqlclient 2.x 版本不想编译的话可以用 pymysql并在项目的__init__.py中执行pymysql.install_as_MySQLdb()但需要在 settings.py 里把 DATABASES 的 ENGINE 保持为django.db.backends.mysql。这里的坑点在于django 的 mysql 后端最终调用的是 MySQLdb 接口pymysql 只是做了兼容层版本相差太大会出现未知列类型报错。3.2 视图里串联上传、识别、入库三步Django 的视图层建议只做请求编排把 OpenCV 识别函数单独放到一个 service 文件里不要在 views.py 里写图像处理的循环。视图流程是接收上传图片、保存到临时目录或 BytesIO、调用识别函数得到选项、创建 AnswerRecord、计算总分、把结果用上下文传给模板。import cv2 import numpy as np from django.shortcuts import render from .models import Exam, Student, AnswerRecord from .services.omr import recognize_sheet def upload_sheet(request): if request.method POST: exam Exam.objects.get(pkrequest.POST[exam_id]) student Student.objects.get(pkrequest.POST[student_id]) image cv2.imdecode( np.fromstring(request.FILES[sheet].read(), np.uint8), cv2.IMREAD_COLOR, ) selected recognize_sheet(image, exam.answer_key) for q_no, ans in enumerate(selected, start1): AnswerRecord.objects.update_or_create( studentstudent, examexam, question_noq_no, defaults{selected: ans, is_correct: ans exam.answer_key[q_no - 1]}, ) correct AnswerRecord.objects.filter( studentstudent, examexam, is_correctTrue ).count() return render(request, result.html, { student: student, total: correct, score: correct * 1.0, records: selected, }) return render(request, upload.html)imdecode 加 fromstring 是处理内存图片的常见组合比先写临时文件再 cv2.imread 少一次磁盘 IO也免去清理文件的麻烦。recognize_sheet 返回一个列表元素是 A/B/C/D 字符串也可能是空字符串代表未涂。update_or_create 保证同一个学生同一道题重复提交时不会产生脏数据。这里判分直接用了标准答案列表而 Question 表如果存在更合理的写法是从 Question 表取值否则两个表同时维护同一份标准答案会不一致。模板里的展示也不只是把分数打印出来而是要把识别结果与原题号一一对应方便老师看出具体是哪道题错了。比如在下方标出错误题目编号一个紧凑的列表示例如下{% for record in records %} tr td{{ forloop.counter }}/td td{{ record }}/td td{{ answer_key|slice:forloop.counter0 }}/td /tr {% endfor %}forloop.counter 从 1 开始answer_key 是列表用 slice 过滤器取对应下标。很多人会把 answer_key 写成字符串“ABCDABCD”然后在模板里用 slice 配合长度参数但列表在 Python 视图里更好操作后端直接比较也不会出错。这里还能顺手判断 record 和标准答案是否相等在模板里用{% if %}渲染一个红色叉号虽然效果朴素但比让用户自己去后台看 JSON 要直观得多。3.3 调试页把中间过程图像塞进 HTML没有中间过程可视化视觉系统排错很痛苦。我会额外加一个debug_upload视图不在判分流程里使用而是在开发时保留把透视矫正后的图、每个答案格裁剪图直接转成 Base64 字符串放进 context模板里显示出来。import base64 import cv2 def encode_debug_image(cv_image): _, buffer cv2.imencode(.jpg, cv_image) return base64.b64encode(buffer).decode(utf-8) # 模板中: img srcdata:image/jpeg;base64,{{ img }} /这样的调试页面能一眼看出问题出在哪个环节。透视矫正图正常但答案格裁得不对是投影分隔的阈值参数问题矫正图本身是歪的则需要回头检查 order_points 里的顶点顺序逻辑。这个页面不需要任何前端框架一个 HTML 模板加一个 dict 上下文就够用但它能把调试效率提高一大截。4. 判分规则与边界处理多选、漏涂、涂改如何不丢分判分比看起来要复杂得多。单选题直接比对字符串多选题要求选项集合相等涂改如果不干净识别结果可能是“AB”或“ABC”这时简单字符串比较会把多选、漏选、错选搅在一起。需要先定义规则严格匹配、容忍次序、按点给分。4.1 把图像识别结果转成规范选项字符串OpenCV 识别出的每个答案格都有独立的是否涂写标记需要把这些二分结果拼成字符串。标准做法是每个题有四个 bool 值按 ABCD 顺序拼出来多个选项之间用空字符串分隔或直接连写def options_to_string(flags): letters ABCD selected [] for idx, flag in enumerate(flags): if flag: selected.append(letters[idx]) return .join(sorted(selected))sorted 很重要。学生先涂 B 再涂 A视觉检测顺序是 B、A但标准答案通常是 A、B 或 AB如果直接用检测顺序拼字符串就会让正确答案 AB 和识别结果 BA 比对失败。排序后字符串唯一后续判分只需判断 selected standard 即可。对于答题卡上没有涂任何选项的情况options_to_string 返回空字符串需要在入库前判空。我一般会单独标记为“未填”跟“错选”区分开因为未填可能在成绩统计里不扣分或者要触发人工复查流程。4.2 多选判分的三种常见策略策略规则适用场景全对才给分selected standard标准单选题、严格多选少选给部分分selected 是 standard 的子集时给 0.5月考、平时作业按命中率计分命中数 / 标准选项数需要保留区分度在系统里实现时先判断答案类型。可以在 Exam 模型上增加 question_type 字段也可以沿用 Question 表的 score 字段score1 的单选score2 的多选。判分函数写成独立模块返回分值而不是布尔值这样后续生成报表时可以直接使用。def judge(selected, standard, full_score1.0): if not selected: return 0.0, unanswered if selected standard: return full_score, correct if full_score 1 and set(selected).issubset(set(standard)): return full_score / 2, partial return 0.0, wrongpartial 只回一半分是针对多选“少选”的场景单选如果出现 subset 判断可能是因为识别结果多了一个半截铅笔印这种情况不应该给半分所以把 full_score 1 作为进入部分分的条件。实际判分系统里还要区分“错选 A 但漏选 B”和“多选了一个 C”这两种错误信息在错题展示中很关键只用一个 wrong 状态丢失了细节。4.3 涂改与双选当视觉结果不可信时留给人工复核计算机视觉系统最怕的不是识别不准而是识别不准却不自知。答题卡上出现明显的涂改痕迹时灰度阈值会把橡皮擦残留识别成涂写导致一道单选题出现 AB 两个选项。更稳妥的做法是引入“置信度”维度当某个答案格的覆盖率介于 0.15 到 0.35 之间或者覆盖率大于阈值但不满足一半时系统不自动判分把该题标记为review1。ratio calc_ratio(cell) is_marked ratio 0.2 is_ambiguous 0.35 ratio 0.2 if is_marked and not is_ambiguous: result.append(A) elif is_ambiguous: ambiguous_questions.append(q_no)这道题在 AnswerRecord 里的 is_correct 保持 NULL后续视图层加一个过滤条件单独渲染一个“待复核”表格。管理员点击“确认 A”或“确认未填”后再把结果写回并更新总分。这个小功能虽然只多几十行代码却是答辩时很受评委认可的工程点也顺带解决了真实扫描场景里最常见的橡皮擦残留问题。5. 识别效果怎么验证批量回测、误判定位与 Django 部署前检查把系统从一个能跑的 demo 变成能交付的东西关键在于有数据支撑。我建议先在本地准备一个 test_images 目录里面至少放 30 张不同光照、角度的答题卡照片并手写一份 ground truth.txt格式为“文件名 标准答案字符串”然后用脚本自动跑一遍输出每题错误矩阵。import cv2 from pathlib import Path from services.omr import recognize_sheet gt {001.jpg: ABC, 002.jpg: AAB} for img_path in sorted(Path(test_images).glob(*.jpg)): image cv2.imread(str(img_path)) result recognize_sheet(image, list(gt[img_path.name])) print(img_path.name, result, OK if .join(result) gt[img_path.name] else FAIL)这个脚本看起来简单但能快速暴露三类问题透视矫正是否稳定、答案格坐标是否需要微调、阈值是否受光照影响。把 FAIL 的图片单独归到一个目录人工查看识别中间结果比在 Django 页面里反复上传图片高效得多。部署 Django 到服务器时我的检查顺序有四点。MySQL 表字符集设置为 utf8mb4否则学生姓名里的生僻字存入时可能乱码。执行python manage.py collectstatic之后确保 nginx 有指向 static 目录的 location 配置因为 Django 默认不提供远程静态文件。settings.py 里 DEBUG 必须为 FalseALLOWED_HOSTS 要包含服务器域名或 IP否则上线后一访问就是 400 Bad Request。最后用 gunicorn 或 uwsgi 配合 nginx 启动建议写成 systemd 服务进程意外退出后自动拉起。启动命令里 ExecStart 需要写绝对路径并指向项目里的 wsgi:application路径写错会直接报 module not found这比代码逻辑出错更难排查因为日志里往往只会留下一个简单的退出码。本文还有配套的精品资源点击获取
返回列表