简介:一份基于Python与计算机视觉的答题卡识别及判分系统完整项目包,面向毕业设计、课程设计及论文研究场景,适合需要落地图像识别+Web应用的学生开发者。系统采用Django搭建服务端,借助OpenCV完成图像预处理、边缘检测与答案填涂识别,涵盖灰度化、二值化、轮廓定位及OCR匹配等核心环节。资源压缩包共287个文件,约9.15MB,主要包含Python源码(30个py及编译后pyc)、HTML/CSS/JS前端页面、75个gif演示图、29个png界面截图、数据库SQL文件及说明文档,目录结构完整,便于直接运行与二次开发。已有418人学习下载,配套资料能帮助快速理解答题卡判分逻辑、Django视图与模板交互流程,以及多线程/分布式处理优化思路,是完成课设或毕设的高质量参考。
1. 答题卡识别判分系统:OpenCV 加 Django,这包资源把识别到判分的链路全串好了
很多老师在校考批改时最怕的就是答题卡填涂判断模棱两可,浅一点、擦不干净一点,就得靠人眼反复核对。这套基于 Python 的计算机视觉答题卡识别及判分系统,就是奔着这个场景去的:上传答题卡照片后,OpenCV 负责找边界、切区域、判断填涂,Django 把识别结果接进 Web 服务,前端用 bootstrap 和 layui 打包好了交互界面,跑起来就是一个能上传、能判分、能查成绩的小系统。它非常适合拿来当毕业设计二次开发,也适合课程设计里做完整流程演示。下文按拆包后最实用的代码路径,把图像预处理、后端判分、参数调优和踩坑记录挨个过一遍。
2. 图像预处理流水线:灰度化、二值化、透视校正,一步都不能省
2.1 预处理三件套:为什么灰度化之后还要滤波和二值化
答题卡照片大概率来自手机或者普通扫描仪,光照、角度、纸张底色全都不受控。如果直接拿原图做轮廓检测,RGB 三个通道上的干扰信息会成倍放大,边缘检测结果里全是纹理噪点。常见做法是先把图像标准化:灰度化去色、高斯滤波去噪、大津法二值化把填涂和空白变成黑白分明的两块。这三步做完,后续轮廓检测的输入就干净很多。
import cv2 def prepare_image(image_path): # 读图 img = cv2.imread(image_path) # 灰度化:三通道转单通道,丢掉颜色干扰 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯滤波:5x5 卷积核去细碎噪点 blurred = cv2.GaussianBlur(gray, (5, 5), 0) # 大津法二值化:阈值自动计算,填涂区变成白色 _, binary = cv2.threshold( blurred, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU ) return img, gray, binary逻辑说明:prepare_image返回三份数据,原图留给透视变换后切彩色图用,灰度图方便人工调试,二值图是后面轮廓检测的直接输入。这里有个顺序上的讲究:先滤波再二值化,噪点会被压掉;反过来先二值化再滤波,二值化产生的椒盐点会被卷积核扩散成块,反而更难处理。
参数说明:高斯核(5, 5)是常用起点,核越大去噪越强但边缘越糊,超过 7 时小选项的边缘容易被抹平;THRESH_BINARY_INV让填涂区域为白色前景、空白为黑色背景,和直接展示用的二值图正好相反,后面统计白像素占比时逻辑更顺。THRESH_OTSU只接受单通道输入,所以必须在灰度化之后调用。
实际测试中最常见的误用是:把THRESH_BINARY和THRESH_BINARY_INV搞反,导致填涂区域变成黑色、空白变白,统计面积的数据全部反转,排查半天才发现是这里的问题。
2.2 找答题卡外轮廓并做透视变换
预处理完成后,下一步是定位答题卡本身。手机拍摄角度不可能是正俯视,卡片在图像里通常是个梯形甚至任意四边形,直接按原始坐标切题号区域必然错位。所以要先通过轮廓检测找到四边形角点,再做透视变换把卡片拉正。找轮廓这一步的核心是用面积筛掉干扰框,再用多边形逼近把轮廓简化成四个角点。
import cv2 import numpy as np def find_card_contour(binary): # 只找最外层轮廓,内部选项框不参与 contours, _ = cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) # 按面积从大到小排序,答题卡通常面积最大 contours = sorted(contours, key=cv2.contourArea, reverse=True) for cnt in contours: # 用 2% 周长作为逼近容差,把轮廓压成四边形 peri = cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) == 4: return approx return None def order_points(pts): # 四角排序:左上、右上、右下、左下 # 顺序不对,透视矩阵就错,越是右下角偏移越明显 pts = pts.reshape(4, 2) s = pts.sum(axis=1) diff = np.diff(pts, axis=1).ravel() tl = pts[np.argmin(s)] br = pts[np.argmax(s)] tr = pts[np.argmin(diff)] bl = pts[np.argmax(diff)] return np.array([tl, tr, br, bl], dtype="float32")逻辑说明:find_card_contour用RETR_EXTERNAL只保留最外层轮廓,答题卡的边框内部还有一堆选项框,如果用RETR_TREE会把几百个内轮廓全部返回,排序和筛选都变成灾难。sorted按面积倒序后,正常情况下第一或第二个轮廓就是答题卡本体。order_points把角点按物理位置排序,这一步不能省,直接拿findContours的原始顺序去算透视矩阵,不同拍摄角度下角点顺序不一样,坐标映射就会整体错位。
参数说明:0.02 * peri是多边形逼近容差,容差偏大时角点会被拉偏,边框又粗又脏时建议加到0.03;容差偏小会留下五边形、六边形,就需要继续遍历下一个轮廓。cv2.arcLength(cnt, True)的第二个参数表示轮廓是否闭合,答题卡边框是闭合的,这里必须传 True。
拿到有序角点后做透视变换,把卡片拉成规则矩形:
def four_point_transform(img, pts, width=800, height=500): rect = order_points(pts) (tl, tr, br, bl) = rect # 计算目标矩形真实宽高,避免透视变形导致比例被压扁 w1 = np.linalg.norm(br - bl) w2 = np.linalg.norm(tr - tl) h1 = np.linalg.norm(tr - br) h2 = np.linalg.norm(tl - bl) max_w = max(int(w1), int(w2)) max_h = max(int(h1), int(h2)) dst = np.array([ [0, 0], [max_w - 1, 0], [max_w - 1, max_h - 1], [0, max_h - 1] ], dtype="float32") # 计算透视变换矩阵并应用 M = cv2.getPerspectiveTransform(rect, dst) warped = cv2.warpPerspective(img, M, (max_w, max_h)) # 统一缩放到固定尺寸,方便后续按比例切分 warped = cv2.resize(warped, (width, height)) return warped逻辑说明:先算出目标矩形的实际宽高,再建立变换前后的点对映射,最后用warpPerspective做变换。resize到固定尺寸是为了让后续题号坐标可以用一套写死的比例去切,不需要为每张图重新标定坐标。如果你打算支持多套答题卡排版,这一行可以去掉,直接用max_w和max_h作为切分基准。
参数说明:width=800, height=500是给标准 A4 横版答题卡用的,竖版卡片必须把这两个值对调,否则长宽比会被压坏。另外要注意,warpPerspective的输出尺寸要和dst匹配,这里如果直接写死 800x500,横竖版方向就会乱。
2.3 按题号与选项坐标切分填涂区域
透视校正完成后,答题卡被拉成一个固定矩形。接下来要做的是按坐标把每个题的选项切成小块。项目里常见做法是按行遍历,把题目编号区、选项区、考号区分别切出来。坐标来源一般是先手动量一次答题卡模板,把每题的y_start和y_end记录成配置文件。
def extract_choices(warped_binary, question_config): """ question_config: list of dict 每个元素格式: {'y_start': 100, 'y_end': 140, 'options': ['A', 'B', 'C', 'D']} """ choices = {} for idx, cfg in enumerate(question_config): y1 = cfg['y_start'] y2 = cfg['y_end'] # 每行选项按 x 方向等分 row = warped_binary[y1:y2, :] h, w = row.shape opt_w = w // len(cfg['options']) for i, opt in enumerate(cfg['options']): seg = row[:, i * opt_w:(i + 1) * opt_w] choices[(idx + 1, opt)] = seg return choices逻辑说明:这个函数按行切分,对每行在 x 方向等分。它隐含了一个前提:同一行里每个选项宽度相等,且选项在水平方向均匀排列。如果答题卡排版里 ABCD 是两行两列排列的,就不能用等分切法,要先按列分组再按行切。正常横向四选一的排版下,这套逻辑最稳。
参数说明:y_start和y_end是透视变换后的像素坐标,模板变了就要重新量一次。opt_w = w // len(options)直接取整除,如果答题卡印刷有偏差,边缘选项可能会切到相邻选项,这时可以给每个选项留 2-3 像素的 margin 再统计。
2.4 填涂判定:白像素占比才是硬指标
每个选项切出来后是一小块二值图,填涂与否看白像素占比就行。占比高说明这块区域被铅笔灰覆盖,占比低说明是空白。这里有个容易被新手忽略的细节:判定不能只设一个阈值,还要结合整张卡的填涂占比分布来看。
def is_marked(seg, threshold=0.2): # 填涂区域是白色,统计白色像素占比 h, w = seg.shape total = h * w if total == 0: return False, 0.0 white = cv2.countNonZero(seg) ratio = white / total return ratio > threshold, ratio逻辑说明:cv2.countNonZero统计的是非零像素数,在二值图里就是白色像素数,也就是填涂覆盖的区域。返回值是(是否填涂, 占比),占比留着后面做置信度用。正常用 2B 铅笔填涂时,覆盖率通常在 0.3-0.6 之间;完全没填的选项,残留噪声的占比一般在 0.05 以下,所以 0.2 是个比较安全的初始阈值。
参数说明:threshold=0.2是全局阈值。如果考试允许用 HB 铅笔,填涂会比 2B 浅,阈值要降到 0.12 左右;如果答题卡纸印刷的底色偏灰,阈值要反向调高到 0.25。具体怎么调,第 5 章有专门展开。
3. Django 后端接住识别结果:路由、视图、ORM 三层配合
3.1 模型设计:学生、答题卡、标准答案三张表
识别功能跑通之后,要把结果落到 Web 系统里。Django 负责接收上传请求、调用识别函数、保存成绩。数据模型按功能拆三张表:学生信息、答题卡记录、标准答案。这里的核心设计是AnswerSheet表把识别结果存成 JSON 文本,而不是逐题建行,避免表结构被题目数量绑死。
from django.db import models class Student(models.Model): # 考号,答题卡上通常用涂块表示 student_id = models.CharField(max_length=32, unique=True) name = models.CharField(max_length=64, blank=True) created_at = models.DateTimeField(auto_now_add=True) class AnswerSheet(models.Model): # 一张答题卡对应一次提交 student = models.ForeignKey(Student, on_delete=models.CASCADE) image = models.ImageField(upload_to="sheets/") # 识别结果:{"1": "A", "2": "C", ...} recognized = models.TextField(blank=True) score = models.IntegerField(default=0) submitted_at = models.DateTimeField(auto_now_add=True) class AnswerKey(models.Model): # 标准答案,按场次区分 exam_name = models.CharField(max_length=64) answer_json = models.TextField() total_score = models.IntegerField(default=100)逻辑说明:Student和AnswerSheet是一对多关系,一个学生可以有多条提交记录,方便统计多次测验。recognized用TextField存 JSON,是因为题目数量可能变化,用固定字段存 A/B/C/D 会限制扩展性。AnswerKey单独建表是为了支持多场考试各自的标准答案。
参数说明:student_id设置unique=True时要注意,如果同一考号重复提交,直接建Student会报唯一约束错误。更稳妥的做法是先get_or_create,再关联到AnswerSheet。
3.2 视图函数:把上传、识别、判分串成一条链路
视图函数的关键是注意请求方法判断、文件字段校验、识别结果落库。识别函数必须放在项目内可复用的模块里,视图只做流程编排,不写图像处理逻辑。
import json from django.http import JsonResponse from .models import AnswerSheet, AnswerKey from .recognition import recognize_sheet def grade_sheet(request): if request.method != "POST": return JsonResponse({"error": "请使用 POST 上传图片"}, status=405) image_file = request.FILES.get("sheet_image") if not image_file: return JsonResponse({"error": "缺少 sheet_image 字段"}, status=400) student_id = request.POST.get("student_id", "") exam_name = request.POST.get("exam_name", "") # 创建答题卡记录,保存图片 sheet = AnswerSheet.objects.create( student_id=student_id, image=image_file ) # 调用识别函数,返回 {题号: 答案} recognized = recognize_sheet(sheet.image.path) # 读取标准答案并逐题判分 key = AnswerKey.objects.filter(exam_name=exam_name).first() if key is None: return JsonResponse({"error": "找不到标准答案"}, status=404) key_map = json.loads(key.answer_json) score = 0 per_question = key.total_score // len(key_map) for q_no, ans in recognized.items(): if key_map.get(str(q_no)) == ans: score += per_question sheet.recognized = json.dumps(recognized, ensure_ascii=False) sheet.score = score sheet.save() return JsonResponse({ "score": score, "recognized": recognized, "detail": "判分完成" })逻辑说明:视图先校验请求方法和文件字段,然后创建答题卡记录并把图片落盘,sheet.image.path是 Django 根据MEDIA_ROOT拼接出的本地路径,传给识别函数用。判分逻辑是逐题比对,标准答案 JSON 的 key 是题号字符串,识别结果的 key 是整数,比较前需要统一格式。
参数说明:per_question = key.total_score // len(key_map)是均分算法,100 分 20 题就是每题 5 分。如果你的评分规则不平均,改成在AnswerKey里加一个score_json字段按题号存分值。
3.3 前端模板:引入 bootstrap 和 layui 静态资源
Django 模板系统负责渲染上传页面和成绩页面。项目包里已经打包好了bootstrap.min.css、bootstrap-theme.min.css、font-awesome.css、layui.css这些静态文件,放在static目录下直接引用即可,不需要自己再下载 CDN 版本。
{% load static %} <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>答题卡判分系统</title> <link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}"> <link rel="stylesheet" href="{% static 'css/layui.css' %}"> <link rel="stylesheet" href="{% static 'css/font-awesome.min.css' %}"> </head> <body> <form id="uploadForm" enctype="multipart/form-data"> <input type="file" name="sheet_image" accept="image/*"> <input type="text" name="student_id" placeholder="考号"> <button type="submit" class="btn btn-primary">上传判分</button> </form> </body> </html>逻辑说明:bootstrap 负责页面布局和按钮样式,layui 负责上传组件和结果展示表格。上传方式建议用 layui 的upload.render组件做异步上传,避免整页刷新。如果只做课程设计演示,直接用 form 同步提交也能跑通。
3.4 识别函数模块化:把图像逻辑统一切到一个文件里
整套系统里最容易乱的地方是图像识别代码和 Django 代码混在一起。我一般会把所有 OpenCV 相关函数放到单独的recognition.py里,视图只调用一个入口函数。这样做的好处是,后续想换模板匹配或者加深度学习模型,只改这一个文件。
import cv2 from .preprocess import prepare_image, four_point_transform from .extract import extract_choices, is_marked def recognize_sheet(image_path, question_config, threshold=0.2): # 预处理:原图、灰度图、二值图 img, gray, binary = prepare_image(image_path) # 找外轮廓并透视变换 cnt = find_card_contour(binary) if cnt is None: return {"error": "未检测到答题卡边框"} warped = four_point_transform(img, cnt) warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) _, warped_binary = cv2.threshold( warped_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU ) # 切分选项并判定 choices = extract_choices(warped_binary, question_config) result = {} for key, seg in choices.items(): marked, ratio = is_marked(seg, threshold=threshold) if marked: result[key[0]] = key[1] return result逻辑说明:这个入口函数把预处理、找轮廓、透视变换、切分、判定五个步骤串成一条流水线。question_config由前端或配置文件传入,threshold作为参数暴露出来,方便压测时批量调整。返回的result直接给 Django 视图用,格式是{题号: 选项字母}。
参数说明:这里有一个需要注意的地方,透视变换后的warped是彩色图,所以需要再做一次灰度化和二值化,不能直接复用最初那张binary。原因是透视变换会引入插值噪声,重新二值化可以消除这部分影响。
4. 避坑指南:识别翻车最多的五个环节
4.1 填涂太浅被判成空题
现象:2B 铅笔轻轻涂一遍,肉眼能看清颜色,系统却报“未填涂”。原因:光线不足时,浅色填涂的灰度值接近纸张底色,大津法算出的阈值偏高,把浅涂区域直接划成背景。解决思路有两个方向,一是做预处理增强,二是降低判定阈值。建议先用 CLAHE 做局部对比度增强,再把is_marked的threshold从 0.2 降到 0.12。如果降阈值后空白选项也开始误判,就需要结合第 6 章的置信度机制来兜底。
4.2 橡皮擦不干净导致多选误判
现象:擦除后残留的痕迹被识别成“已填涂”,同一题判出 A 和 C 两个答案。原因:残留的铅笔灰在白像素统计里和正常填涂没有本质区别,只是占比低一些。解决:统计每个选项占比后,先取最大值和次大值,只有当两者的差值大于设定幅度时才判为有效单选。差值太小说明可能是涂抹残留或误擦,标记为“存疑题”交给人工复核。
4.3 透视变换后坐标系统错位
现象:变换前轮廓看着正常,变换后题目区域整体偏移,越靠近右下角偏移越严重。原因:四个角点的顺序没有按左上、右上、右下、左下排好,或者卡片边框上有干扰线导致角点被approxPolyDP拉偏。解决:先检查order_points的输出顺序,再检查找到的四边形是不是答题卡的真实外框。如果外框带阴影,先用cv2.erode腐蚀一次再去逼近多边形,能去掉大部分干扰。
4.4 同一学生重复提交,成绩被覆盖
现象:同一个考号提交两次,数据库里只有最后一次的记录,查不到第一次的成绩。原因:视图里用AnswerSheet.objects.create每次新建记录,前端却没有提示用户已经提交过。解决:提交前先按(student_id, exam_name)查重,存在记录时返回“该生已有成绩”的提示,或者提供“覆盖提交”的确认入口,至少不能在用户不知情的情况下静默覆盖。
4.5 手机拍摄的竖版照片被旋转
现象:手机默认竖拍的照片在浏览器里看着正常,识别结果却全是乱的。原因:Django 的ImageField保存图片时保留了 EXIF 方向信息,而 OpenCV 的cv2.imread不会自动旋转。解决:在prepare_image里读图后检查 EXIF orientation 字段,值为 6 就顺时针旋转 90 度,值为 8 就逆时针旋转 90 度。这个坑最容易在移动端测试时出现,电脑上模拟器传的图通常没带 EXIF,所以不容易发现。
4.6 一次标准排查路径
遇到识别结果不对的时候,我一般按下面的顺序排查,而不是东改一个参数西改一个阈值:
| 排查步骤 | 检查内容 | 常见的翻车点 |
|---|---|---|
| 1 | 原图是否正常读入 | 路径错误、EXIF 方向未处理 |
| 2 | 二值图黑白是否反了 | THRESH_BINARY_INV 写反 |
| 3 | 四边形角点是否定位正确 | 干扰线导致角点偏移 |
| 4 | 透视变换后的切分坐标是否对齐 | 模板坐标过期 |
| 5 | 填涂判定阈值是否合理 | 浅涂、残留同时出现 |
这条路径走一遍,能定位掉八成以上的识别错误。剩下两成基本是答题卡排版本身的问题,放到第 5 章讲。
5. 参数调优与答题卡排版:识别的上限由设计决定
5.1 关键参数速查表
图像识别这套流程里,真正会影响结果的参数不超过六个。把这六个数记清楚,遇到问题基本能猜到是哪个环节出的问题。
| 参数 | 推荐值 | 作用 | 调整方向 |
|---|---|---|---|
| 高斯核大小 | 3x3 - 5x5 | 去噪强度 | 图越脏核越大,超过 7 会糊掉选项边框 |
| 多边形逼近容差 | 0.02 * peri | 控制顶点数量 | 边框粗时加到 0.03,否则角点偏移 |
| 二值化方法 | THRESH_OTSU | 自动阈值 | 光照不均时改为 CLAHE 增强后再二值化 |
| 填涂判定阈值 | 0.2 | 区分填涂与否 | 浅涂降到 0.12,纸底色深则调高 |
| 轮廓面积筛选 | 图像面积 * 0.3 | 过滤小干扰框 | 多卡同框时收紧到 0.5 |
| 透视变换目标宽高 | 800 x 500 | 统一坐标系 | 竖版答题卡对调宽高 |
这里特别提一下二值化阈值。很多人以为THRESH_OTSU是万能的,实际上它假设图像直方图是双峰分布,一张纸上有大量空白、少量填涂正好符合这个假设。但如果答题卡背面有透印的图形,直方图会变成多峰,OTSU 算出来的阈值就会偏向其中一个大峰。这种情况下,改成固定阈值加局部自适应是更稳的选择,虽然牺牲了一点通用性。
5.2 答题卡排版规范:识别率在印刷时就决定了
识别率的上限不是由算法决定的,而是由答题卡排版决定的。项目里踩过的最深的坑是:拿到一张设计精美的答题卡,配色花哨、选项间距小、背景带底纹,算法怎么调都拉不回来。后来总结出一条经验:系统的识别能力在答题卡设计阶段就已经被框死了。
答题卡设计需要遵守以下几个规则。选项间距不能太窄,每个选项区域在 800x500 的变换后图像里至少要占 30 像素宽,否则切分时容易切到相邻选项。题号区和选项区之间要留白,避免题号数字的笔画和选项的填涂区域连在一起。纸张底色不能用深灰或彩色底纹,大津法会把底纹当成前景,二值化后整个画面都是白的。边框线要清晰且粗细均匀,最好用连续的矩形框而不是四个分开的 L 形角标,后者在拍摄角度偏大时容易被轮廓检测漏掉。
如果答题卡是网上找的 PDF 模板转成图片,务必先打印一张实测,不要直接在屏幕上看着好看就下单印刷。印刷机的墨量偏差会影响边框粗细,复印机的缩放比例会影响坐标切分,这些误差在算法里都会放大成错位。
5.3 阈值调优看反馈曲线,不要拍脑袋改数字
调参最忌讳的就是“感觉不对就改一下”。正确的做法是把填涂和未填涂两个类别的白像素占比分布画出来,找到两个分布之间的分割点。这个分割点才是当前光照、打印质量下的最优阈值。
import numpy as np from collections import defaultdict # choices: dict {(题号, 选项字母): 二值图} # 先统计所有选项的白像素占比 ratios = {} for key, seg in choices.items(): _, ratio = is_marked(seg, threshold=0.0) # threshold 传 0 只看占比 ratios[key] = ratio # 按题目维度聚合,观察已填选和未填选的分界 grouped = defaultdict(list) for (q_no, opt), ratio in ratios.items(): grouped[q_no].append((opt, ratio)) # 手动判断:打印每个题最大占比和次大占比 for q_no, item_list in grouped.items(): item_list = sorted(item_list, key=lambda x: -x[1]) print(f"题 {q_no}: 最大 {item_list[0][1]:.3f}, 次大 {item_list[1][1]:.3f}")逻辑说明:这段代码把所有选项的占比都算出来,然后按题打印最大值和次大值。你拿 30 张真实答题卡跑一遍,会看到填涂选项的占比集中在 0.3-0.6,空白选项集中在 0.05 以下。两道分布之间的缝隙就是最优阈值位置,而不是想当然的 0.2。
参数说明:threshold=0.0表示不筛掉任何选区,只为了拿到原始占比数据。如果某张图的填涂区域占比普遍偏低,比如 0.2-0.35 之间,另一张图却普遍偏高,说明手机拍照角度不一致,需要先统一拍摄规范,而不是改阈值硬扛。
6. 进阶技巧:给每个答案加置信度,区分“真会”和“蒙对”
前面讲的判分逻辑是硬判定:占比超过阈值就算填涂,没超过就不算。这套逻辑在填涂干净时表现很好,但遇到浅涂、残留、涂改就会暴露出边界问题。一个实用的进阶方案是给每个答案算一个置信度分数,把“认真填涂的确定性回答”和“模棱两可的猜测”区分开。
置信度的计算不复杂:每个选项都有一个白像素占比,把这些占比从高到低排序。占比最高的选项大概率是被选中的,占比第二高的选项可能是残留或误涂。如果最高和第二高之间差距很大,说明填涂非常明确;如果两者接近,说明这个题的填涂状态存疑。把这两个信息合成一个置信度指标:
def confidence_score(ratios): """ ratios: dict {选项字母: 白像素占比} 返回 0-1 之间的置信度,越高越确定 """ ranked = sorted(ratios.items(), key=lambda x: -x[1]) top1 = ranked[0][1] top2 = ranked[1][1] # 差距部分:最高占比与次高占比的差 diff = min(top1 - top2, 1.0) # 绝对值部分:最高占比本身 return round(0.6 * top1 + 0.4 * diff, 3)逻辑说明:top1是最可能被填涂的选项占比,top2是第二可能的占比。top1高说明填涂覆盖充分,diff大说明唯一性强。两个指标加权合成,占比高但差距小的题目会得到中等置信度,这种题目往往就是橡皮擦没擦干净的区域。
实际使用时的操作是:置信度高于 0.45 的题目直接采信最高占比选项;置信度在 0.2-0.45 之间的标记为“存疑题”,导出列表交给老师人工确认;低于 0.2 的按未填涂处理。这个阈值不是固定的,跑 50 张历史答题卡,统计识别结果与人工复核结果的匹配率,找到拐点作为当前批次的最优值。
把置信度落进 Django 视图也很简单,在AnswerSheet表加一个report_json字段,存每道题的置信度明细。前端收到后,用 layui 的表格组件展示时把存疑题标成黄色背景。实际试用时我发现,加了这一层之后,老师复核的工作量直接减少了大概七成,因为真正需要看的只有那些存疑题,其他题目系统判错的概率极低。
这套系统现在给我的使用感受是:只要答题卡排版按第 5 章的规范来,识别率就稳得住;只要置信度机制在,边界情况就不会变成漏网之鱼。从那以后我每次跑识别流程都会强制生成一份带置信度的报告,把低分题导出 CSV 复盘,从算法参数到相机角度全部过一遍。这个习惯帮我避免了很多“这次能跑通、下次换一批图就翻车”的尴尬。希望这套拆解能帮你在课程设计或毕业设计里少走几个弯路。
本文还有配套的精品资源,点击获取