基于OpenCV的答题卡识别判分系统:流程拆解与常见坑解析
简介面向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, keycv2.contourArea, reverseTrue) 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 NoneCanny 的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), dtypefloat32) # 坐标和最小的点是左上角坐标和最大的点是右下角 s pts.sum(axis1) rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] # 坐标差y - x最小的是右上角最大的是左下角 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_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] ], dtypefloat32) # getPerspectiveTransform 要求输入输出各四个点顺序必须严格对应 M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (max_width, max_height)) return warpedorder_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_gap5): 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_threshold0.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_ratio0.10): # 8 连通把离散的小噪声和真正的填涂区域分开 num_labels, labels, stats, _ cv2.connectedComponentsWithStats(cell, connectivity8) # 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_ratiomin_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, encodingutf-8) as f: return json.load(f) def grade_sheet(detected, answer_key, allow_emptyFalse): 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, detailsallow_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(clipLimit2.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 threshblockSize31表示每个像素的阈值由其周围 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, dtypenp.uint8) img cv2.imdecode(data, cv2.IMREAD_COLOR) if img is None: raise ValueError(f图片解码失败: {path}) return imgnp.fromfile把文件所有字节读进来cv2.imdecode负责从内存中的字节流还原图像完全不依赖文件系统对中文路径的处理。换电脑前在代码入口加一行打印cv2.__version__如果新环境版本不是你要的在 requirements.txt 里写死版本号比如opencv-python4.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_imagesrun_pipeline是所有环节的总入口detected和expected都转换成{题号: [选项]}格式后再比对。不要用总分做评估指标因为总分只反映最终成绩而逐格比对能暴露“某一道题被漏判”和“某一道题多选了一个选项”之间的差别。评估完成后把正确率、失败数量、失败原因作为注释写进 README答辩时直接展示。我在这类项目里踩得最多的并不是算法本身而是第 5.4 节的答案错位和第 5.2 节的光照不均前者靠可视化标记找出来后者靠 CLAHE 加自适应阈值解决。每次调完参数都建议把参数值写进配置文件的注释里否则三天后你自己都会忘记为什么阈值是 0.2 而不是 0.25。希望这套从流程到踩坑的拆解能帮你在毕业设计里少走弯路。本文还有配套的精品资源点击获取