news 2026/9/16 7:39:28

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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, key=cv2.contourArea, reverse=True)[: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), 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 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]], dtype="float32") 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(axis=1) vertical = binary.sum(axis=0) # 找白色像素连续出现区间的边界 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(axis=1) 统计每一行有多少白色像素,行与行之间的空隙对应白色像素为 0 的波段,从而还原出每道题的纵向位置。实际项目中我不会直接用连续 0 判断,而是计算列方向的投影直方图,再用滑动窗口过滤掉噪点,否则铅笔印或污点会打乱间隔。

另一种更省事的方案是:先手工标定一张“标准答案卡”模板,记录每个答案格的圆心坐标,运行时只做透视矫正,再用固定坐标去答案格中心采样。两种做法对比如下:

方案开发量鲁棒性适用场景
投影自动分隔高,需处理粘连和噪点好,版式变化也能适应多套答题卡、卡片不是严格印刷
模板固定坐标低,直接存 JSON 或数据库差,答题卡模板不能变单套卡、考试场景固定

如果你做的答辩演示只针对一张模板,我会建议先用模板坐标;如果想让系统在验收时能换第二张模板,投影分隔会让代码看起来更像“计算机视觉”。

2.3 涂写状态判定:覆盖率与连通域计数

每个答案格已经是一个 ROI 小图,怎么判断它有没有被涂满?最简单的做法是计算答案格内非白色像素的比例。但铅笔灰度往往介于背景和印刷框之间,直接用非黑即白二值化会不稳定。我一般先统计 ROI 内像素值小于某个阈值的数量,再除以 ROI 面积得到覆盖率。

def is_filled(cell, threshold=0.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 > threshold

threshold 参数一般在 0.15 到 0.3 之间。比值设得过高会把浅涂误判为未填,设得过低会把答案格边框或噪点算成涂写。对于答题卡中的 A/B/C/D 方框,0.2 是个不错的起点。更严谨的做法是配合形态学开运算,先去掉孤立噪点,再用连通域计数过滤掉面积小于 30 像素的小块,避免橡皮擦残留造成误判。这一层处理的质量,直接决定后面判分系统拿到的是干净选项还是带杂讯的脏数据。

3. Django 前后端与 MySQL:把识别结果变成可提交的成绩

Django 在这个系统中的角色不只是提供 API,而是承担了表单上传、session 管理、模板渲染和数据库读写。用 Django 模板写 HTML 页面,意味着服务端渲染的前端页面不需要单独部署 node,后端把识别结果直接塞进 context 返回给浏览器即可。“完整前后端”在课设里通常就是这么落地:不需要 Vue 或 React,Django 的 template 语法足够支撑题目配置、成绩查看和错误标注页面。

3.1 先设计 MySQL 表结构,再写 model

答题卡判分系统的核心数据不是一张“成绩表”,而是要能追溯“某一个学生、某一次考试、某一题、选了哪个选项、得分多少”。如果只存总分,后面想展示错题分布就无从下手。我会最少建四张表:考试表、学生表、答题记录表、题目表。用 Django ORM 表达如下:

from django.db import models class Exam(models.Model): name = models.CharField(max_length=100) answer_key = models.JSONField(default=list) # ["A","B","C",...] created_at = models.DateTimeField(auto_now_add=True) class Student(models.Model): student_no = models.CharField(max_length=20, unique=True) name = models.CharField(max_length=50) class Question(models.Model): exam = models.ForeignKey(Exam, on_delete=models.CASCADE) question_no = models.PositiveIntegerField() answer = models.CharField(max_length=10) score = models.FloatField(default=1.0) class AnswerRecord(models.Model): student = models.ForeignKey(Student, on_delete=models.CASCADE) exam = models.ForeignKey(Exam, on_delete=models.CASCADE) question_no = models.PositiveIntegerField() selected = models.CharField(max_length=10, blank=True) is_correct = models.BooleanField(null=True)

answer_key 用 JSONField 存标准答案,在 Python 里就是 list,MySQL 8 对应 JSON 类型;如果 MySQL 版本较老或希望查询更直观,可以改用 CharField 加逗号分隔。Question 表看起来和 answer_key 重复,但它能存每道题的分值,方便做不同题型权重。AnswerRecord 是判分明细,is_correct 留空表示这道题还没判,比如学生涂了两个选项时系统可以标记为待复核。

对应迁移命令:

python manage.py makemigrations python manage.py migrate

makemigrations 会根据 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(pk=request.POST["exam_id"]) student = Student.objects.get(pk=request.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, start=1): AnswerRecord.objects.update_or_create( student=student, exam=exam, question_no=q_no, defaults={"selected": ans, "is_correct": ans == exam.answer_key[q_no - 1]}, ) correct = AnswerRecord.objects.filter( student=student, exam=exam, is_correct=True ).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 src="data: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 字段:score=1 的单选,score=2 的多选。判分函数写成独立模块,返回分值而不是布尔值,这样后续生成报表时可以直接使用。

def judge(selected, standard, full_score=1.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, "wrong"

partial 只回一半分是针对多选“少选”的场景,单选如果出现 subset 判断,可能是因为识别结果多了一个半截铅笔印,这种情况不应该给半分,所以把 full_score > 1 作为进入部分分的条件。实际判分系统里还要区分“错选 A 但漏选 B”和“多选了一个 C”,这两种错误信息在错题展示中很关键,只用一个 wrong 状态丢失了细节。

4.3 涂改与双选:当视觉结果不可信时,留给人工复核

计算机视觉系统最怕的不是识别不准,而是识别不准却不自知。答题卡上出现明显的涂改痕迹时,灰度阈值会把橡皮擦残留识别成涂写,导致一道单选题出现 AB 两个选项。更稳妥的做法是引入“置信度”维度:当某个答案格的覆盖率介于 0.15 到 0.35 之间,或者覆盖率大于阈值但不满足一半时,系统不自动判分,把该题标记为review=1

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 必须为 False,ALLOWED_HOSTS 要包含服务器域名或 IP,否则上线后一访问就是 400 Bad Request。最后用 gunicorn 或 uwsgi 配合 nginx 启动,建议写成 systemd 服务,进程意外退出后自动拉起。启动命令里 ExecStart 需要写绝对路径,并指向项目里的 wsgi:application,路径写错会直接报 module not found,这比代码逻辑出错更难排查,因为日志里往往只会留下一个简单的退出码。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 7:38:50

CentOS 7安装Git全指南:yum与源码编译详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 7:36:50

架构测试的双视图:静态依赖检查+动态运行观测,缺一不可

架构测试这件事&#xff0c;圈子里有个很普遍的误区&#xff1a;不少人以为把分层依赖检查挂到 CI 上&#xff0c;静态扫描全绿&#xff0c;架构就算测过了。可真实情况往往是——静态视图一切正常&#xff0c;线上却因为一次反射调用绕过分层、或者某个服务超时被重试放大&…

作者头像 李华
网站建设 2026/9/16 7:35:23

Linux 串口驱动

一、Linux 下 UART 驱动框架1、uart_driver 注册与注销同 I2C、SPI 一样&#xff0c;Linux 也提供了串口驱动框架&#xff0c;我们只需要按照相应的串口框架编写驱动程序即可。串口驱动没有什么主机端和设备端之分&#xff0c;就只有一个串口驱动&#xff0c;而且这个驱动也已经…

作者头像 李华
网站建设 2026/9/16 7:35:18

搭建招商网站平台完整流程:从域名备案到上线避坑指南

搭建招商网站平台完整流程:从域名备案到上线避坑指南 域名解析报错 404,服务器后台登录密码忘了,或者最致命的——ICP 备案被驳回,理由是你填的“网站名称”不符合规范。很多老板在启动招商项目时,第一反应是找外包公司要报价,结果发现报价单上全是“服务器配置”、“带宽大小”这些看不懂的术语。域名和服务…

作者头像 李华
网站建设 2026/9/16 7:35:10

鸿蒙ArkTS开发入门:从环境搭建到HAP调试实战

1. 项目概述&#xff1a;这不是“又一本鸿蒙教程”&#xff0c;而是一条可踩实的入门路径“鸿蒙开发从入门到精通之一”——这个标题乍看平平无奇&#xff0c;甚至有点像被用烂的营销话术。但如果你最近刷过技术社区、翻过招聘JD、或者在华为开发者联盟官网停留过三分钟&#x…

作者头像 李华
网站建设 2026/9/16 7:35:01

SpringBoot集成Quartz时QRTZ_LOCKS表缺失问题解析

1. 问题现象与背景分析最近在启动一个SpringBoot后端项目时&#xff0c;控制台突然抛出异常&#xff1a;"Failure obtaining db row lock: Table linfengcommunity.QRTZ_LOCKS doesnt exist"。这个错误看似简单&#xff0c;但背后涉及SpringBoot的定时任务调度机制、…

作者头像 李华