☰
基于OpenCV的答题卡识别判分系统:流程拆解与常见坑解析
2026/10/7 1:46:03 网站建设 项目流程

简介:面向Python毕业设计与课程设计的学生,这份源代码实现了一个完整的答题卡识别与判分系统。系统基于OpenCV进行图像预处理、选项定位与答案比对,并与MySQL数据库联动,能够将判分结果导出为Excel,适合作为计算机视觉相关课题的参考项目。资源包共293个文件,整体大小15.36MB,内容覆盖Python源码、前端展示页面、图像样式素材、SQL数据库脚本及项目说明文档等,其中Py/Pyc源码便于直接运行调试,HTML/CSS/JS用于操作界面,PNG/GIF等图像文件辅助理解识别过程。已有92人学习,可据此判断项目具备一定的参考价值。配套资料提供了完整的开发环境和模块化设计思路,包括图像去噪、二值化、边缘检测、选项识别等关键步骤,按照毕业设计报告要求梳理了功能逻辑与数据流。拿到后可以快速复现系统,也可在此基础上扩展题库管理、成绩统计分析等功能,对完成论文和答辩准备都有实际帮助。

1. 基于计算机视觉的答题卡识别及判分系统:先看清这套源码能解决什么问题

手工改一张 50 题的答题卡,再快也要一两分钟;把同一张答题卡拍成照片,让基于计算机视觉的答题卡识别及判分系统去跑,光照正常、版式固定的情况下,0.5 秒就能输出成绩单。这类 python 毕业设计的核心并不是什么高深模型,而是把图像预处理、透视校正、填涂区域检测和规则判分串成一条稳定的流水线。理解这条流水线的人,拿到源码后能自己改题数、改选项个数、加多选判分、做批量判分;不理解的人,往往卡在“代码能跑但识别率奇低”这一步,最后只能拿一张图硬凑结果交差。这篇笔记就按“先立流程,再写代码,最后填坑”的顺序,把这条流水线彻底拆开。

2. 答题卡识别判分的整体流程:先理清 OpenCV 方案里各环节的输入与输出

2.1 为什么毕业设计优先选 OpenCV 而不是 YOLO 深度学习

很多同学一看到“计算机视觉”就往深度学习上靠,想着用 YOLO 训练一个目标检测模型直接框出答题卡和填涂区域。这个思路在商品检测、车牌识别里没问题,但放到答题卡场景里属于杀鸡用牛刀。

答题卡的版式是极其固定的:题号有序排列,每个题号对应固定数量的选项,填涂区域是大小一致的矩形。你要做的不是“理解图像内容”,而是“精确测量每个格子里有没有被填涂”。这种任务用 OpenCV 的轮廓检测、透视变换、像素统计就能完成,而且结果完全可解释——哪个格子的黑色像素占比是多少,你都能拿出来给老师看。换成深度学习,反而要面对训练样本不足、填涂深浅不一致、铅笔笔迹和印刷文字混在一起的问题,模型对这些噪声的泛化能力并不好。

另外,这套系统最终要跑在普通笔记本上,甚至要能接入网站做在线判分。OpenCV 加上 numpy 的推理几乎没有显存要求,单张图像处理时间在毫秒级;深度学习模型一上来就多了权重文件、推理框架、GPU 依赖,对毕业设计来说部署成本远大于收益。常见做法是把 OpenCV 定位成“图像前端”,如果以后真要做手写填空识别,再在判分环节之前挂一个 OCR 模型,两者互不影响。

所以如果你拿到源码后发现核心 import 是 cv2 和 numpy,不要觉得它“不够人工智能”。能把固定版式的填涂区域识别做到 99% 以上,这个项目的工程价值已经足够了。

2.2 一张答题卡从拍照到出分,要经过的 6 个处理环节

整套系统在逻辑上可以拆成 6 个环节,每个环节的输入输出必须非常清楚,否则后面排错时你会不知道问题出在哪一环。

环节输入输出常见手段
图像读取图片路径BGR 图像数组cv2.imread 或 cv2.imdecode
图像预处理原图二值化图像灰度化、高斯模糊、Otsu 阈值
答题卡定位二值化图像答题卡四个角点Canny 边缘检测、findContours
透视校正原图 + 四个角点正射俯视的答题卡cv2.getPerspectiveTransform、warpPerspective
填涂区域切分与检测正射图每个选项格子的填涂状态投影切分、像素占比统计
判分与输出填涂状态 + 答案模板分数、判错标记、结果图规则匹配、可视化标注

前两个环节决定“图像质量”,中间两个环节决定“几何位置”,最后两个环节决定“分数是否正确”。很多源码跑出来的分数偏低,问题并不在判分逻辑,而在第二步和第三步——答题卡在照片里是斜的,切分格子时坐标全部错位,后面的判分自然全部作废。

这里我特别建议你把整条链路想象成一条流水线:“拍照 → 拉正 → 找格 → 读数 → 打分”。每一步的输出都保存成一张中间图,调试时一张一张看,哪里断了立刻知道。后面第 3 章和第 4 章会给到可直接运行的代码,先把流程跑通,再调参数。

2.3 拿到 python 源码包后怎么从 main.py 开始梳理代码

这类毕业设计源码包解压后,目录结构通常逃不出下面这个骨架。注意我这里的说法是“通常”,每个作者命名习惯不同,但你一定要先找到入口脚本,而不是一头扎进某个工具函数里。

AnswerSheetJudge/ ├── main.py # 入口:命令行解析、流程编排 ├── config.py # 常量:题数、选项数、阈值、答案路径 ├── answer_sheet.py # 答题卡处理:预处理、透视、切分 ├── grade.py # 判分逻辑 ├── utils.py # 可视化、保存结果 ├── requirements.txt └── data/ ├── sample.jpg └── answer_key.json

我一般会按“配置文件 → 入口脚本 → 处理函数 → 判分函数”的顺序来读。先看 config.py,因为题数、选项数、二值化阈值、答案文件路径都在里面;再看 main.py,搞清楚流程从哪张图开始、中间调用了哪些函数;最后才进入 answer_sheet.py 看具体实现。不要先看 utils.py,那里通常只是画框、保存图片的辅助代码,看早了容易迷失。

环境搭建也建议按固定顺序操作,不要直接在全局 python 环境里 pip install。新建虚拟环境,再读 requirements.txt,最后跑一张样例图确认能出结果:

cd AnswerSheetJudge python -m venv venv # Windows 激活:venv\Scripts\activate # Linux / macOS 激活:source venv/bin/activate pip install -r requirements.txt python main.py --image data/sample.jpg --config config.py

上面的命令里有几个参数值得说明。--image指定输入图片路径,--config指定版式配置;如果源码作者把参数写死在 main.py 里,你需要先看代码里默认的图片路径在哪里,把样例图放到对应位置再跑。遇到ModuleNotFoundError: No module named 'cv2'这类错误,说明 opencv-python 没装进当前虚拟环境,千万别直接 sudo 装到系统环境里,否则后面换机器时环境根本没法复现。

跑通一张图之后,再做一件事:把输入图片换一张角度稍微偏斜的照片试一次。如果识别率明显下降,说明这套源码的透视校正部分比较脆,正好对应第 3 章要讲的内容。

3. 图像预处理与透视校正:把倾斜照片拉正成可判分的标准图

3.1 灰度化、高斯模糊与 Otsu 二值化的参数设置

答题卡识别对图像的要求只有两条:背景和前景要分得开,填涂区域和其他文字要留得住。彩色图里包含太多光照和阴影信息,所以第一步先转灰度图。然后做一次高斯模糊,目的是抑制纸张纹理和摄像头传感器噪声。核大小取 (5, 5) 即可,取太大容易把铅笔浅色边缘抹掉,取太小等于没做。

接下来是二值化。这里有个关键点:标准答题卡是白底黑字黑填涂,你要让“有内容”的区域变成白色,方便后面统计像素。于是二值化要用THRESH_BINARY_INV,把前景反转成白色。如果不用 INV,后面的像素占比统计就会反着算,这是最常见的翻车点之一。

import cv2 import numpy as np def preprocess(image_path): # cv2.imread 直接读,中文路径可能失败,先按普通路径处理 img = cv2.imread(image_path) if img is None: raise FileNotFoundError(f"无法读取图片: {image_path}") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) # Otsu 会自动计算阈值,返回的 thresh 值后面调试要用 _, thresh = cv2.threshold( blurred, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU ) return img, gray, thresh, _

这段代码里有两个参数值得你调。第一个是高斯模糊的(5, 5),如果你的图片分辨率很高、打印的选项框边缘很锐利,可以改成(3, 3)保留更多边缘细节;如果图片噪点很多,可以改成(7, 7)但要注意浅色铅笔痕迹可能被揉掉。第二个是 Otsu 本身没有手写阈值,它会在 0 到 255 之间自动找一个最佳分割点。问题在于 Otsu 假设图像直方图是双峰的——背景一堆峰、前景一堆峰。如果你的答题卡有阴影、有浅色水印,直方图变成多峰,Otsu 找出来的阈值就会把填涂区域的一部分当成背景丢掉。

遇到这种情况,不要急着改 Otsu,先用一个临时方案看看到底丢在哪里:把二值化结果用cv2.imwrite存成图,肉眼看哪些黑色填涂没变成白色。确认是光照问题后,再换自适应阈值,这个坑在第 5 章专门展开。

3.2 通过轮廓检测找出答题卡的四条边

答题卡在照片里几乎不可能是完全水平的,你要识别它靠的是“纸张外边缘”和背景之间的灰度跳变。在二值化图像上做 Canny 边缘检测,然后找轮廓。答题卡通常占据画面大部分面积,所以按轮廓面积从大到小排序,第一个面积足够大且能被approxPolyDP拟合成四边形的轮廓,就是答题卡的外边框。

def find_answer_sheet_contour(thresh): # Canny 的低阈值和高阈值,需根据边缘强度调整 edges = cv2.Canny(thresh, 50, 150) # OpenCV 4.x 的 findContours 只返回两个值,这里是兼容写法 contours, _ = cv2.findContours( edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) contours = sorted(contours, key=cv2.contourArea, reverse=True) for cnt in contours: area = cv2.contourArea(cnt) if area < 1000: continue peri = cv2.arcLength(cnt, True) # epsilon 取周长的 2%,太小会保留多余角点,太大会把四边形拟合成三角形 approx = cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) == 4: return approx.reshape(4, 2) return None

Canny 的50, 150是低阈值和高阈值。图像对比度强时,这两个值可以调高,比如80, 180,避免把纸张内部的表格线也当成外轮廓;对比度弱时,要调低到30, 100才找得到纸张边缘。RETR_EXTERNAL表示只取最外层轮廓,因为答题卡外框一定比内部表格线更靠外。CHAIN_APPROX_SIMPLE则会压缩轮廓点,减少后续计算量。

这段代码最常见的返回结果是“找到 5 个角点”或者“返回 None”。前者说明 epsilon 设小了,approxPolyDP把边缘上的一个小凸起也当成了角点,把0.02改成0.03或0.04;后者说明 Canny 没检测出完整的边缘,通常是光照太强导致边缘断裂,需要先做一次形态学闭运算把断裂的边缘接起来。

3.3 four_point_transform 透视变换的关键代码

拿到四个角点后,需要把照片里倾斜的四边形映射成一个正的矩形。这一步要做两件事:先把四个角点按“左上、右上、右下、左下”的顺序排好,再通过透视变换矩阵把图像内容重新投影到目标矩形上。

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)] # 坐标差(y - x)最小的是右上角,最大的是左下角 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_top = np.linalg.norm(tr - tl) width_bottom = np.linalg.norm(br - bl) max_width = max(int(width_top), int(width_bottom)) height_left = np.linalg.norm(tl - bl) height_right = np.linalg.norm(tr - br) 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") # getPerspectiveTransform 要求输入输出各四个点,顺序必须严格对应 M = cv2.getPerspectiveTransform(rect, dst) warped = cv2.warpPerspective(image, M, (max_width, max_height)) return warped

order_points使用“坐标和”和“坐标差”来区分角点,这是最简单也最常用的方法。但它的前提是答题卡必须是凸四边形,而且四个角点里不能有某个角因为遮挡或者轮廓断裂导致坐标明显偏移。如果你发现变换后的图是梯形甚至是对角线翻转的,先输出pts打印各点坐标,检查排序逻辑是否把右上和左下搞混了。另一种常见做法是先计算四个点组成的四边形的质心,再根据每个点相对质心的方位判断它属于哪个角,比 sum/diff 更稳。

透视变换的输出大小由max_width和max_height决定。如果答题卡是竖版 50 题,宽高比大约是 A4 纸比例;如果摄像头拍出来是侧向的,程序会自动交换宽高。但这里要注意,变换后的图像分辨率不要太大,否则后面的投影切分运算会很慢;也不要太小,否则选项格子的边缘会被压缩得看不清。常见做法是把max_width限制在 1000 到 1500 像素左右,宽高比按检测结果自动缩放。

做完透视变换后,立刻把warped存成图片,肉眼确认三个位置:四个角是否直角、第一题第一格是否完整、最底部的题号是否被截断。任何一点不对,都别急着往下走,否则后面切分出来的全是废数据。

4. 填涂区域定位与判分逻辑:从“看得到”到“算得出分数”

4.1 根据版式坐标切出每个选项格子的代码

透视校正后的图像是正射俯视图,接下来要把每一道题的每一个选项格子精确切出来。这里有两种思路:如果答题卡版式完全固定,可以在 config 里写死每个格子的起始坐标;如果希望程序自动适应不同版式,那就用投影法找到行与列的分割线。

投影法更通用,它利用的是“选项格之间有空白间隙”这个事实:把二值化图像分别按行和列求和,空白间隙对应的行或列求和值会急剧下降,这些下降点就是切分边界。

def get_split_points(projection, blank_threshold, min_gap=5): splits = [] in_gap = False gap_start = 0 for i, value in enumerate(projection): # value 小于阈值,说明这一行或这一列几乎全黑,属于空白间隙 if value < blank_threshold and not in_gap: in_gap = True gap_start = i # value 重新变大,说明间隙结束,取间隙中点作为切分线 elif value >= blank_threshold and in_gap: if i - gap_start >= min_gap: splits.append((gap_start + i) // 2) in_gap = False return splits

这段代码里的projection是一个一维数组:行投影就是把二值化图像每一行的白像素个数累加起来,列投影就是把每一列的白像素个数累加起来。blank_threshold决定什么样算是“空白”,一般取图像宽度或高度的 0.1 到 0.2 倍;min_gap是防止噪点造成误判,如果空隙小于 5 个像素,就当作印刷噪声而不是真正的分隔。

得到行切分线和列切分线之后,还需要根据版式找出选项格子。比如一张卡五行题,每行十道题,每道题四个选项,那么列切分线会把一行切成“题号区、A 区、B 区、C 区、D 区”几个区域。你不需要把所有格子全部枚举,只需要在 config 里定义“第一道题的第一个选项在哪一行哪一列”,然后用固定的步长依次推进。

实际写切分函数时,我习惯把结果保存成一个字典:{'question_index': 1, 'option_index': 0, 'box': (x1, y1, x2, y2)}。这样后面判分时不管题目怎么排布,只要按 key 取值就行。

4.2 填涂判定:像素占比和连通域面积两种方案怎么选

选项格子切出来之后,判定是否填涂,最直接的方法是统计格子内白像素占比。由于前面做了THRESH_BINARY_INV,填涂区域会变成白色;印刷的选项字母 A、B、C、D 也会变成白色,但它们的面积远远小于一个填涂框。所以你可以设一个阈值,比如格子内白像素超过 20% 就认为被填涂。

def is_bubble_filled(cell, ratio_threshold=0.20): if cell.size == 0: return False white_pixels = np.count_nonzero(cell) filled_ratio = white_pixels / cell.size return filled_ratio >= ratio_threshold

这个方案的优点是简单、快,缺点是怕噪声。如果二值化阈值没调好,纸张上的阴影、脏点、甚至选项字母笔迹都可能让白像素占比虚高。所以更稳妥的做法是在像素占比之前,先做一次连通域分析,取最大的连通域面积作为“填涂面积”。

def is_bubble_filled_by_connected_component(cell, min_area_ratio=0.10): # 8 连通,把离散的小噪声和真正的填涂区域分开 num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(cell, connectivity=8) # num_labels 包含背景 0,所以真实连通域从 1 开始 if num_labels <= 1: return False # 取最大连通域的面积 max_area = 0 for i in range(1, num_labels): area = stats[i, cv2.CC_STAT_AREA] if area > max_area: max_area = area return max_area / cell.size >= min_area_ratio

min_area_ratio取 0.10 到 0.12 比较合理。为什么不能太小?因为格子边缘的印刷框线在二值化后也可能形成一圈连通域,如果占比阈值只有 0.03,框线就可能被误判成填涂。为什么不能太大?因为铅笔笔迹不是均匀涂满的,笔芯留下的空隙可能导致最大连通域面积变小,阈值超过 0.15 后浅色铅笔容易漏判。

两种方案不是互斥关系,我在实际项目中会把它们串起来:先用像素占比做一个粗筛,占比低于 0.05 直接判未填涂;占比高于 0.4 直接判已填涂;中间地带再用连通域确认。这样可以兼顾浅色铅笔和散点噪声。

4.3 单选、多选和错选漏选的计分规则

判分逻辑是整个系统里最简单的部分,但也是最容易写错的部分。你要先把识别结果整理成标准格式,比如detected = {'1': ['A'], '2': ['A', 'C']},然后和答案模板逐题比对。答案是单选还是多选,由答案文件里每道题的值是字符串还是列表决定。

import json def load_answer_key(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) def grade_sheet(detected, answer_key, allow_empty=False): total = len(answer_key) correct = 0 details = {} for q_num, expected in answer_key.items(): # 规范成列表比较,避免字符串和列表的差异 if isinstance(expected, str): expected = [expected] got = detected.get(str(q_num), []) # 多选和单选都按集合匹配,顺序不影响结果 is_correct = sorted(got) == sorted(expected) # 选择“全空要扣分”时,空答案直接判错 if not allow_empty and len(got) == 0: is_correct = False details[q_num] = { "expected": expected, "detected": got, "correct": is_correct } if is_correct: correct += 1 score = round(correct / total * 100, 2) if total else 0 return score, details

allow_empty是一个容易被忽略的参数。毕业设计里常见的规则是漏选也算错,那allow_empty保持 False;如果你的需求是“未填涂的题不计分”,就把它设为 True。标准答案的格式建议统一用 JSON,不要硬编码在 Python 文件里。这样更换答题卡版本时,只需要改 JSON 文件,不用改代码。

另外,多选的判分还有两种口径:一种是“完全匹配才给分”,另一种是“选对一部分给一半分”。上面的代码实现的是前者,这也是大多数课程考试采用的口径。如果你要改成后者,判分逻辑里就得额外计算交集大小。我建议毕业设计最好用完全匹配,因为规则清晰,答辩时也容易解释。

5. 判分系统最常见的 5 个坑:从图像噪声到逻辑错位都能定位

5.1 现象:透视变换后答题区域还是歪的,格子对不齐

我在第一次用手机斜拍答题卡时就遇到过这个问题:four_point_transform跑完后,图片确实变成了矩形,但题号区域和选项区域出现了梯形畸变,后排格子和前排格子没有对齐。

原因通常是order_points的角点排序出错了。斜拍角度较大时,最上方的角点不一定是“左上”,它可能被识别成“右上”;再加上 Canny 检测出的边缘有锯齿,approxPolyDP拟合出的角点位置有几十像素的偏移,透视矩阵就会带着错误输入计算。

解决方法是先打印出检测到的pts,在图片上用cv2.circle把四个角点标出来,确认每个角点的物理位置。如果角点本身打错了,后面任何修正都没用。角点位置没问题但变换后仍然歪,就把approxPolyDP的 epsilon 调大一点,或者对轮廓先做cv2.convexHull得到凸包再拟合。凸包能避免纸张边缘轻微凹陷导致角点内收。

5.2 现象:大面积阴影导致 Otsu 二值化把填涂区域全部吞掉

这是最让人头疼的坑。答题卡放在桌面上,拍照时靠近外侧的地方往往有阴影,Otsu 阈值只算出一个全局值,阴影区域的灰度被压暗,二值化后填涂笔迹和背景直接黏在一起,后面的轮廓检测跟着全错。

一个常见的解决思路是先做光照校正。OpenCV 里可以用cv2.createCLAHE做对比度受限的自适应直方图均衡化,它能拉伸局部对比度,让阴影区域的暗部和亮部重新分得开。

def preprocess_illumination(gray): # clipLimit 控制对比度增强强度,太高会把噪声也放大 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray_eq = clahe.apply(gray) # 自适应阈值比 Otsu 更能保留局部细节 thresh = cv2.adaptiveThreshold( gray_eq, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 10 ) return thresh

blockSize=31表示每个像素的阈值由其周围 31×31 邻域的加权平均值决定。这个值要跟图片分辨率匹配,分辨率越高,blockSize 也要适当增大,否则答题卡边缘的粗线条会被切成一块一块的。10是常量减法,相当于给阈值加了一个偏移,避免纸张本身的白度把背景误判为前景。这套预处理组合能解决大部分光照不均问题,代价是计算量比 Otsu 大一些,但对一张 1080p 的答题卡照片来说完全可以接受。

5.3 现象:铅笔填涂太浅,像素占比阈值死活不对

试卷要求用 2B 铅笔填涂,但实际扫描的照片里,用户可能用了 HB,甚至用了普通签字笔。HB 铅笔的灰度值和纸张很接近,二值化后填涂区域的白像素占比只有 10% 到 15%,低于常见的 20% 阈值,于是大量漏判。

解决不是简单把阈值从 0.20 降到 0.10,因为降阈值的同时会把格子里的印刷字母和噪点一起判成填涂,误判率反而上升。更好的做法是改变判定特征:不要统计“白像素占比”,而是统计“填涂区域与格子边缘的灰度差异”。具体做法是先用灰度图而非二值化图处理,把格子中心区域的灰度均值和格子边缘区域的灰度均值做差,差值超过某个范围就判为填涂。

def is_filled_by_contrast(gray_cell): h, w = gray_cell.shape if h < 10 or w < 10: return False # 取中心 60% 区域作为候选填涂区 center = gray_cell[int(h*0.2):int(h*0.8), int(w*0.2):int(w*0.8)] center_mean = np.mean(center) # 边缘区域通常是白纸底色,作为参考亮度 edge = np.concatenate([ gray_cell[0:int(h*0.1), :].flatten(), gray_cell[-int(h*0.1):, :].flatten(), gray_cell[:, 0:int(w*0.1)].flatten(), gray_cell[:, -int(w*0.1):].flatten() ]) edge_mean = np.mean(edge) # 中心比边缘暗 30 个灰度级以上,认为填涂 return (edge_mean - center_mean) > 30

灰度差值法对光源敏感度低一些,因为答题卡各处光照一致时,中心区域和边缘区域的灰度差是稳定的。它的缺点是如果填涂笔迹很浅而且不均匀,中心均值可能只比边缘暗 20 个灰度级,此时可以把 30 调低到 20。实际判定时我建议把像素占比、连通域、灰度差三种信号都算出来,三个里至少两个判“填涂”才认定填涂,这种投票机制在手写笔迹识别里非常实用。

5.4 现象:答案模板与答题卡题号错位,单选被判成多选

有一次我调好识别流程,准确率却始终在 70% 左右,而且错题都集中在每一行的中间位置。检查发现:我的切分程序把“题号区”当成了 A 选项,而真正的 A 选项是它右边那一列。答案文件的第一个 key 是 1,但代码在读取时是从数组第 0 个位置开始遍历的,导致每一道题都往左偏了一列。这道题本该选 A,识别结果却变成了“B”,如果 B 区域刚好填涂,就会多选出两个选项。

这种问题非常隐蔽,因为程序不会报错,只会静默地输出一个看似合理的低分。解决办法是强制要求所有判断题号、选项的代码都基于“坐标 + 编号”的映射表,而不是用数组下标推断题号。具体做法是在config.py里定义一个question_grid = {'1': {'A': (x1, y1, x2, y2), ...}},哪怕写的时候费事,后期换版式时也不会出现全局错位。

调试的时候还有一个技巧:把识别结果可视化,在原图上把每个格子的选项字母和是否“已填涂”直接标出来,立刻就能看出错位方向。一张图胜过十次断点调试。

5.5 现象:在本地能跑通,换电脑或打包后 cv2 报错

这类源码常见的运行异常集中在三个地方:一是图片路径包含中文,cv2.imread直接返回 None,代码进入死循环;二是 OpenCV 版本差异导致函数返回值数量发生变化,典型的如findContours在 OpenCV 3.x 和 4.x 中的返回值不同;三是requirements.txt没锁定版本,新环境装到了兼容性不同的版本。

对于中文路径,不要用cv2.imread,改用cv2.imdecode配合numpy.fromfile。这在 Windows 系统上尤其常见,因为用户的照片经常放在“桌面/新建文件夹”这种带中文的路径下。

def load_image(path): # 先用 numpy 读成字节流,再交给 cv2.imdecode,解决中文路径问题 data = np.fromfile(path, dtype=np.uint8) img = cv2.imdecode(data, cv2.IMREAD_COLOR) if img is None: raise ValueError(f"图片解码失败: {path}") return img

np.fromfile把文件所有字节读进来,cv2.imdecode负责从内存中的字节流还原图像,完全不依赖文件系统对中文路径的处理。换电脑前,在代码入口加一行打印cv2.__version__,如果新环境版本不是你要的,在 requirements.txt 里写死版本号,比如opencv-python==4.8.1.78。最稳妥的做法是把生成环境的pip freeze结果存成 requirements.txt,而不是手动写一行opencv-python。

6. 进阶办法:用 20 张错位样本把识别率测到 99% 再收工

只跑通一张样例图不代表系统完成,毕业设计答辩时老师最爱问的一句话是“你的准确率是多少”。如果你回答“我没测过”,前面所有代码都白写了。所以最后这一步,我建议你花半天时间搭一个最简单的评估流程,这也是收费质量控制和工程上真正值钱的地方。

准备 20 到 30 张不同光照、不同角度、不同深浅的答题卡图片,先人工给每张图标注“正确答案”,然后写一个批量脚本调用判分函数,统计正确率。这里的“正确率”不是总分,而是逐格统计:系统判的填涂状态和人工标注的填涂状态完全一致才算对。一键跑完后,如果准确率低于 99%,把错误样本图输出出来,按第 5 章的分类去排查是哪一类问题。

批量评估的代码可以复用判分逻辑:

def evaluate_directory(image_dir, config, answer_key): total_cells = 0 correct_cells = 0 fail_images = [] for image_path in sorted(image_dir.glob("*.jpg")): try: result = run_pipeline(str(image_path), config) detected = extract_detected_options(result) expected = load_manual_labeled_result(image_path) total_cells += len(expected) correct_cells += compare_cells(detected, expected) except Exception as exc: fail_images.append((image_path.name, exc)) accuracy = correct_cells / total_cells if total_cells else 0 return accuracy, len(fail_images), fail_images

run_pipeline是所有环节的总入口,detected和expected都转换成{'题号': ['选项']}格式后再比对。不要用总分做评估指标,因为总分只反映最终成绩,而逐格比对能暴露“某一道题被漏判”和“某一道题多选了一个选项”之间的差别。评估完成后,把正确率、失败数量、失败原因作为注释写进 README,答辩时直接展示。

我在这类项目里踩得最多的并不是算法本身,而是第 5.4 节的答案错位和第 5.2 节的光照不均;前者靠可视化标记找出来,后者靠 CLAHE 加自适应阈值解决。每次调完参数,都建议把参数值写进配置文件的注释里,否则三天后你自己都会忘记为什么阈值是 0.2 而不是 0.25。希望这套从流程到踩坑的拆解能帮你在毕业设计里少走弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询