恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
基于Python计算机视觉的答题卡识别设计与实现
首页
资讯中心
/
基于Python计算机视觉的答题卡识别设计与实现
基于Python计算机视觉的答题卡识别设计与实现
发布时间:2026/10/5 5:30:31
简介这是一份基于Python计算机视觉的答题卡识别与判分毕业设计项目包含完整源码、数据库及说明文档面向计算机相关专业学生和需要快速搭建答题卡自动批改系统的开发者。资源围绕图像预处理、边缘检测、选项定位、答案比对等关键环节展开覆盖了从答题卡结构分析到自动评分、结果导出的全流程。压缩包共294个文件以30个py源码、32个pyc依赖文件、12个HTML页面及26个CSS样式为核心辅以75个gif演示动图、29个png界面截图、1个SQL数据库脚本和2个docx说明文档整体大小为23.21MB目录按功能模块划分便于查阅。目前已有535人学习下载。说明文档详细阐述了答题卡识别与判分技术中的常用算法并给出了系统需求分析、总体设计及各功能模块登录、首页、题卡识别、题卡管理、题卡分析、用户管理的实现思路实验部分还提供了环境配置、数据集说明和结果分析适合作为毕业设计参考或二次开发基础。1. 基于Python计算机视觉答题卡为什么是毕业设计里最稳的落地题毕业设计最怕的是把题目做得像科幻片而基于Python计算机视觉答题卡的设计与实现这个题目恰好落在常见节奏上它有一条清晰的图像处理链路从灰度化、滤波、二值化到轮廓检测、透视校正、选项区域切割和像素统计判定每一步都对应教科书里的经典算法下游还带一个数据库模块识别出来的成绩能按学生、班级、考试场次入库查询。这个闭环拿到答辩现场可以说清“每个参数为什么这么设”和“错误出现在哪个环节”适合本科阶段计算机视觉或图像处理课程设计也适合小规模培训机构的机读卡阅卷。下面把一条不用深度学习也能稳定跑通的实现方案完整拆开。2. 先把结构和模板定下来答题卡系统的架构、选型与版面设计写代码之前先把“答题卡识别”要做的事拆开输入是一张答题卡照片或扫描图输出是学号、卷号、每道题的选择结果和总分。输入到输出是一条单向流水线分五个环节图像读取、预处理、答题区域定位、选项判定、结果入库。我在第一版开发时跳过架构直接写视觉代码结果每次换一台同学电脑测试就出一个问题原因不是代码写得少而是每一步的输出没有约定好。比如预处理之后得到的是什么坐标系下的二值图轮廓检测得到的矩形框按什么顺序排列这些没有定义清楚后面没法排查。把数据流理清之后写代码只是把它翻译成Python。图像处理部分只依赖一张答题卡模板描述文件模板告诉系统“有多少道题、每道题几个选项、选项圆心横纵间距多少像素”。模板和算法分离的好处是以后换一版答题卡只需要改描述文件而不动识别逻辑课程设计做完之后如果想继续加功能改动成本也会低很多。2.1 系统模块拆分与数据传递约定我习惯把工程拆成三个包core放预处理和识别dao放数据库操作models放答题卡模板描述类。core的输入是图像路径或numpy ndarray输出是一个二维列表行是题号列是该题每个选项的涂填置信度dao只负责拿到这个置信度矩阵和标准答案比对后再写入成绩表。core不依赖dao也就是说在数据库没配好的情况下单张图片也能跑通识别流程这个解耦在后期调试和答辩演示时非常有用。下面这个表是我写进工程说明里的模块接口约定模块输入输出core.preprocess图片路径灰度图、二值图core.locate二值图答题区域四点坐标core.warp原图、四点坐标透视校正后的正面图core.extract正面图、模板参数每题各选项置信度core.score置信度矩阵、标准答案判题明细与总分dao.save学号、考试名、判题明细写入数据库从表里能看出来模块之间的数据都定义成简单类型没有互相纠缠的自定义对象。新手在这里最容易犯的错误是让core内部直接查询数据库比如往定位函数里传一个学号去查模板参数。数据库一旦连不上整个识别全挂这就是传说中可维护性上的地雷。2.2 答题卡的版面设计定位块、题号区与选项区的最小要求答题卡系统能不能跑通一半在代码另一半在版式。很多人拿到网上现成的答题卡扫描测试最后发现轮廓检测、透视校正看着正常但选项区域切割总对不齐那不是算法问题是原版式的黑块位置和间距没有规律没法用统一网格描述。这里给一个比较明确的建议自己画模板。标准单页A4版式需要三类信息区。定位块放在答题区域四角面积要够大打印出来至少是1厘米乘1厘米的黑色方块作用是让轮廓检测稳定找到最大矩形。题号区从1到N按固定行距排列每行对应一个题号选项区是每个题号右侧的A到E五个圆。圆直径建议控制在4到6毫米中心距控制在8毫米这样在普通打印精度下有足够的分割容错。手机拍照场景里透视校正最多能纠正大约30度的倾斜角超过这个角度圆会变成明显的椭圆后面像素统计就很难补救。学号区域也要单独说一句。最省事的做法是考生手写学号识别完人工录入一次如果追求全自动需要在版式里预留一组0到9的填涂块识别方式与普通选项一致。毕业设计时间不充裕时我建议先不做OCR式学号识别把精力留在答题识别主链路上手写学号加人工关联足够应付演示。2.3 Python环境与库版本选型能少碰的坑就不要碰环境方面这个项目不依赖深度学习框架不需要GPU只要Python能装上OpenCV就能跑。版本选型有一个常见被忽略的问题OpenCV 4.x的Python接口在findContours等函数的返回值上和老教程不一致新手容易读到返回两个值的旧代码在自己的环境里解包报错。建议项目从开始就固定在一个openCV 4.x环境内开发不要再混装其他版本。numpy一般由opencv自动带上来不用额外手动同时安装。安装命令很简单python -m pip install --upgrade pip python -m pip install opencv-python numpy如果提示numpy版本冲突常见处理是先卸载再重装让pip自己解析匹配关系不要手动锁死一个不兼容的版本。数据库方面建议优先用Python标准库自带的sqlite3因为课程设计演示最忌讳环境依赖太多等基本功能跑通后可以再根据课题要求换成MySQL代码里只改连接数据库的一个函数表结构和SQL语句几乎不动。3. 核心识别流程从答题卡原图到成绩表的完整Python实现识别主链路要跑得稳核心是把预处理、定位、切割这些步骤的细节处理好并且每一步都单独保存中间结果来观察。下面按顺序写完整实现代码基于OpenCV 4.x。3.1 预处理灰度化、高斯模糊与自适应二值化第一版里我也直接用固定阈值二值化把大于某个灰度值的像素全部变白后来发现中午和傍晚在教室拍出来的答题卡完全是两种亮度固定阈值需要手动调。换成自适应阈值之后光照不均的问题基本被挡在源头。import cv2 import numpy as np def preprocess_image(image_path: str): 读取答题卡图片返回原图、灰度图和二值图 img cv2.imread(image_path) if img is None: raise ValueError(f无法读取图片: {image_path}) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊卷积核取5x5抑制纸张纹理噪声同时保留选项圆边缘 blurred cv2.GaussianBlur(gray, (5, 5), 0) # 自适应阈值根据局部邻域亮度计算阈值抗光照不均 # THRESH_BINARY_INV 把涂黑的选项变成白色区域方便后续找轮廓 # blockSize25 表示局部邻域边长25像素C15 表示在局部均值基础上再减15 binary cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 25, 15 ) return img, gray, binary这段代码里最需要盯住的是blockSize和C这一组参数。blockSize越大对局部光照变化的敏感度越低但也可能忽略很细的铅笔线C越大二值化后保留的白色像素越少。对直径约30像素的选项圆来说blockSize取25、C取15是一个比较稳的起点。如果扫描图噪声很多把C提到18如果涂痕很浅把C降到10。还有一个容易混的点自适应阈值输出的是“涂黑区域为白色”和固定阈值正好相反后续轮廓检测和像素统计都基于这个约定。注意二值化结果不要随手另存为jpg后再读进来做下一步jpg压缩会在选项圆边缘制造一圈噪点连续两次有损压缩会对小圆区域占比产生可感知的影响。中间过程一律用png保存。3.2 用轮廓检测和透视校正定位答题区域做完二值图后下一步是找到答题卡所在的矩形区域。最简单的方法是找整张图中面积最大的外部轮廓然后假设答题卡是画面里最大的四边形。这里要依赖前面反转二值图的原因白色区域才是findContours要抓的对象纸张背景是黑色题号和选项圆连成的大块白色区域形成可检测轮廓。def find_sheet_contour(binary): 在原二值图中找到面积最大的四边形轮廓返回四个角点 # RETR_EXTERNAL 只取最外层轮廓过滤掉选项圆内部可能出现的孔洞 contours, hierarchy cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if not contours: return None # 答题卡在画面里占比最大所以取面积最大的轮廓 largest max(contours, keycv2.contourArea) # 用周长的2%作为近似精度把锯齿状边界拟合成四边形 peri cv2.arcLength(largest, True) approx cv2.approxPolyDP(largest, 0.02 * peri, True) if len(approx) ! 4: # 如果拟合法得到的不是4个点说明画面里没有一张完整的矩形答题卡 return None return approx.reshape(4, 2)approxPolyDP的0.02是经验值表示近似曲线与原始轮廓之间的最大距离不超过周长的2%。值太小会把纸张边缘的小凸起也保留结果不止4个点值太大则会把圆角或折角拉平。答题卡本身是矩形0.02通常够用。拿到四个角点后做透视校正。四个点如果直接传入getPerspectiveTransform顺序必须固定为左上、右上、右下、左下否则输出图像会错乱。通常做法是先排序角点再计算变换。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 warp_sheet(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) height_left np.linalg.norm(bl - tl) height_right np.linalg.norm(br - tr) 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) warped cv2.warpPerspective(image, M, (max_width, max_height)) return warped这里预先计算目标矩形的宽高取两条横向边和纵向边的较大值避免答题卡被压缩变形。warpPerspective输出的尺寸完全取决于目标矩形后面的网格切割按模板等比换算即可。有一个边界要记住如果答题卡本身倒着放角点排序会得到一张颠倒的正面图这个问题留到避坑章节展开。3.3 按模板切割选项区域网格划分与圆形掩膜统计透视校正后的图是矩形下面的任务就是把它分成题号行和选项列。网格法依赖固定的模板几何参数比如题目数量、选项数、单元格宽高。为了让切割结果能对上印刷位置模板里的坐标通常在开发阶段通过人工测量打印图得到而不是在运行时自动计算。def extract_options_by_grid(warped_binary, rows: int, cols: int, cell_w: int, cell_h: int): 按固定网格统计每个选项区域的涂填占比 rows: 题号行数 cols: 选项数通常为5 cell_w, cell_h: 模板中每个单元格的宽高 height, width warped_binary.shape[:2] choices np.zeros((rows, cols), dtypefloat32) for r in range(rows): for c in range(cols): x1 c * cell_w y1 r * cell_h x2 min(width, x1 cell_w) y2 min(height, y1 cell_h) roi warped_binary[y1:y2, x1:x2] if roi.size 0: continue # 二值图中涂黑区域是255直接求均值再归一化就得到涂填占比 choices[r][c] cv2.mean(roi)[0] / 255.0 return choices网格矩形的边缘不可避免地会带进选项之间的空白这会拉低所有选项的均值但不会影响“哪个选项占比最高”的排序。如果想让置信度本身更准确最有效的改进是用圆形掩膜而不是矩形网格去统计。模板里记录每个圆的圆心坐标和半径就能精确统计圆内被涂黑的像素比例。def extract_options_with_mask(warped_binary, centers, radius: int): 用圆形掩膜统计每个选项的涂填占比降低矩形边缘干扰 centers: 与题号行对应的圆心坐标列表 radius: 选项圆半径单位像素 height, width warped_binary.shape[:2] choices np.zeros((len(centers), len(centers[0])), dtypefloat32) for r, row_centers in enumerate(centers): for c, (cx, cy) in enumerate(row_centers): # 每个选项单独生成局部掩膜避免把相邻选项统计进来 mask np.zeros((height, width), dtypeuint8) cv2.circle(mask, (cx, cy), radius, 255, -1) roi cv2.bitwise_and(warped_binary, warped_binary, maskmask) choices[r][c] cv2.countNonZero(roi) / (3.14159 * radius * radius) return choicescountNonZero统计的是圆内被涂黑的部分除以圆面积得到涂填占比。正常2B铅笔填涂的覆盖率通常超过0.5残留铅灰大多在0.1以下。判定阈值取0.35是比较保守的起点正式调试时再根据实际扫描效果浮动。3.4 评分逻辑多选与漏选怎么处理选择题评分不是简单地把每个选项占比最高的挑出来。考场最常见的状况是某道题没涂或者涂了又改没擦干净。这里必须明确规则单一阈值下被标出多个选项的题以及所有选项占比都低于阈值的题都算错误但结果里要保留标记供人工复核。def score_choices(choices, answer_key, threshold0.35): 返回总分、总题数和逐题明细明细里保留未涂和多涂状态 correct 0 total len(answer_key) details [] for q_no in range(total): scores choices[q_no] marked [i for i, v in enumerate(scores) if v threshold] if len(marked) 0: details.append((q_no, -1, 0)) # -1 表示未涂 continue if len(marked) 1: details.append((q_no, marked, 0)) # 多涂按错处理保留原始选项 continue choice chr(ord(A) marked[0]) is_correct int(choice answer_key[q_no]) correct is_correct details.append((q_no, choice, is_correct)) return correct, total, detailsthreshold直接影响多选误判。调太低浅铅笔灰也被算作选项没擦干净的B和真正涂满的D同时被标记题就变成多选调太高涂得不实的选项会被判漏选。遇到卷面特别不稳定的情况先对单张图像看二值化效果再针对性调整C值不要一上来就同时改三四个参数那会让问题变成黑匣子。4. 数据库模块成绩表结构、入库与批量查询识别链路跑通后数据库部分的重点是表设计、事务写法和字符集设置。表结构设计得细一点后面做“哪道题全班错得最多”这类分析时有数据可查也好给答辩增加信息量。4.1 先SQLite还是直接MySQL演示型项目如何留切换空间课程设计最常见的硬性要求是不能只把结果写进文件所以数据库总要落地。建议主用SQLite原因是零依赖导师电脑上没有MySQL服务也不会当场翻车。如果后续想做Web端或多人同时提交在代码层做一个统一的连接函数切换MySQL时只改连接配置SQL语句不需要重写。网络上关于mysql数据库修改结构、数据库同步软件的讨论很多但对单机演示项目来说并没有引入必要先跑通SQLite比一上来就维护一套MySQL环境稳得多。对比项SQLiteMySQL安装Python标准库自带需要单独安装服务并发单机低并发够用支持多客户端并发交付一个.db文件即可需要提供建库脚本适用本地演示、单机阅卷Web平台、批量处理中心4.2 建表SQL学生表、考试表与逐题明细表这里建三张表student保存学生档案answer_sheet保存某次考试识别的总成绩和图片路径answer_detail保存每个题号的答案和判分结果。明细表看起来多一步但价值很大统计某道题得分率时一条SQL就能出来不用把总分里的信息拆来拆去。CREATE TABLE IF NOT EXISTS student ( student_id VARCHAR(20) PRIMARY KEY, class_name VARCHAR(50) NOT NULL, student_name VARCHAR(50) NOT NULL ); CREATE TABLE IF NOT EXISTS answer_sheet ( sheet_id INTEGER PRIMARY KEY AUTOINCREMENT, student_id VARCHAR(20) NOT NULL, exam_name VARCHAR(50) NOT NULL, image_path VARCHAR(200), total_score INTEGER, submit_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (student_id) REFERENCES student(student_id) ); CREATE TABLE IF NOT EXISTS answer_detail ( id INTEGER PRIMARY KEY AUTOINCREMENT, sheet_id INTEGER NOT NULL, question_no INTEGER NOT NULL, answer VARCHAR(10), is_correct INTEGER, FOREIGN KEY (sheet_id) REFERENCES answer_sheet(sheet_id) );student_id是学生表主键而不是自增ID因为学号在业务上唯一且稳定。answer_sheet用自增主键同一学生可参加多次考试每次生成独立记录。若以后数据量变大可以考虑给answer_sheet的student_id、exam_name建联合索引现阶段数据量在几千行时不需要避免过度设计。4.3 Python入库与按班级查询事务和编码一次写对保存一次识别结果的流程是先插入answer_sheet主表拿到自增主键再插入明细表。必须用事务包裹否则明细中途出错会留下“总成绩有了、逐题明细缺失”的残缺数据。import sqlite3 from contextlib import closing DB_PATH answer_sheet.db def get_connection(db_path: str DB_PATH): return sqlite3.connect(db_path) def save_sheet_result(student_id, exam_name, image_path, correct, total, details, db_pathDB_PATH): with closing(get_connection(db_path)) as conn: cur conn.cursor() try: # 手动开启事务主表和明细表要么全部写入要么全部回滚 cur.execute(BEGIN) cur.execute( INSERT INTO answer_sheet (student_id, exam_name, image_path, total_score) VALUES (?, ?, ?, ?), (student_id, exam_name, image_path, correct) ) sheet_id cur.lastrowid for q_no, answer, is_correct in details: cur.execute( INSERT INTO answer_detail (sheet_id, question_no, answer, is_correct) VALUES (?, ?, ?, ?), (sheet_id, q_no 1, str(answer), is_correct) ) conn.commit() except Exception: conn.rollback() raise return sheet_id在Windows上用SQLite读取中文班级名时偶尔会出现乱码常见做法是在连接建立后加一行conn.text_factory str让Python侧统一按文本处理避免SQLite把数据以bytes形式传给界面层。按班级统计平均分和考试次数是最常用的聚合需求SQL里用JOIN连接student表再按class_name和exam_name分组def get_class_score_summary(class_name: str, db_path: str DB_PATH): sql SELECT s.class_name, a.exam_name, COUNT(*) AS sheet_count, AVG(a.total_score) AS avg_score FROM answer_sheet a JOIN student s ON a.student_id s.student_id WHERE s.class_name ? GROUP BY a.exam_name with closing(get_connection(db_path)) as conn: conn.text_factory str cur conn.cursor() rows cur.execute(sql, (class_name,)).fetchall() return rows如果换成MySQL只要把连接函数换成pymysql指定hostlocalhost, userroot, password***, databaseanswer_sheet, charsetutf8mb4SQL主体不用改。有一点要提前检查建库时把字符集定为utf8mb4不然演示现场一旦输入中文班级名出现乱码会很尴尬。5. 搬砖级避坑手册识别率上不去的几个现场与排查答辩现场最容易翻车的三个点是识别结果不对、换机器跑不起来、数据库数据乱掉。下面按实际遇到的问题写五条排查记录每一条都按现象、原因、解决三步拆方便对号入座。5.1 涂得满满的一格却被判为“未选”现象图片看起来正常、透视校正也正常但某一题所有选项都没超过阈值整张成绩单缺一道题总分不对。原因可能是二值化参数不匹配比如自适应阈值里的C值偏大浅铅笔痕被直接过滤掉也可能是判选阈值定在0.35而实际涂卡覆盖率在0.28到0.35之间差一点点没够到。解决分两步。先保存一张二值化中间图cv2.imwrite(debug.png, binary)肉眼看涂黑区域内部是否完整再把threshold临时降到0.25重跑。如果原来漏选的题能被识别说明问题在固定阈值而不是版式。正式项目里最好把判定阈值写进配置文件不同批次的打印纸灰度可以通过配置调节不要写死在代码里。5.2 一道题同时框出两三个选项现象某题的A、C、D判定占比都超过阈值被标成多选直接计错。最常见的原因是橡皮擦残留铅灰还有一种是扫描时纸张轻微偏移切割网格按理想坐标切下去ROI边界侵入了相邻圆框。解决分两层。第一层对每个选项区域做连通域分析cv2.connectedComponentsWithStats可以得到每个连通域的面积小于约120像素的斑点是杂点或铅灰可以过滤。第二层在评分规则里把多选标记为“需要人工复核”而不是直接当成普通错误丢弃。关键是不能在预处理阶段就把所有杂点清得干干净净那会同时削弱真实的浅涂答案保留标记后面还能导出题目号去核对原图。5.3 透视校正后的图像位置反而偏移网格对不齐现象斜拍图校正后整体角度对了但选项和网格线出现了两三个像素的偏移越往右下角越明显。原因有三个层次。单应变换基于四个角点把整个平面拉正对纸张不平整造成的局部弯折无能为力角点检测坐标本身有误差扭曲会被进一步放大order_points的坐标排序法在答题卡旋转90度时也会直接错乱。解决建议角点排序不要只依赖坐标求和。先计算轮廓的最小外接矩形中心以中心点为原点按极角排序能应对一定程度的旋转。更好的做法是如果模板四角设计了黑色定位块直接用定位块的最小外接矩形做变换比用整张纸的轮廓稳定得多。5.4 OpenCV版本不同导致同一套代码换台机器就跑挂现象本机一切正常换到教室电脑上运行报错要么是findContours解包时值数量不对要么是某个同名函数参数对不上。原因网上大量旧代码基于OpenCV 3.xfindContours返回值规范已经调整不同版本之间部分算法也有接口变化。同学之间互相拷贝代码时没有统一依赖是另一个导火索。解决办法项目根目录提交requirements.txt并在说明文档里写明“基于OpenCV 4.x运行”。答辩前找一台干净机器重新执行一次python -m pip install -r requirements.txt如果这一步能跑答辩机器上大概率不会卡在环境问题上。生成requirements文件只需一条命令python -m pip freeze requirements.txt5.5 数据库写入中文乱码、总成绩字段出现0现象SQLite或MySQL里班级名变成问号或answer_sheet里的total_score不是识别算出的正确值。先说乱码。SQLite在Windows下连接时如果连接对象把字符串以bytes形式交给界面层中文可能被截断MySQL侧更常见的问题是建表时没指定utf8mb4。再说成绩异常总分出现0通常是评分函数返回的元组位置写反比如把(correct, total, details)写成了(total, correct, details)插入语句里又按位置取到了total。解决SQLite连接加conn.text_factory strMySQL连接参数加charsetutf8mb4并确认表字符集是utf8mb4。成绩异常先打印评分函数返回值确认correct范围在0到总题数之间再检查插入SQL的取参顺序。这类肉眼不容易看出的问题给评分函数写一个简单的单元测试最划算用固定choices喂进去断言返回的correct必须等于预期值。6. 从能用到好用自测样本、中间过程可视化与两个进阶细节项目从“能跑”到“可靠”差别通常在验证方式。我习惯在拿到真实答题卡之前先用程序生成一张模拟答题卡来验证整条链路。生成的样本可以先随机填涂故意留一两个空用来确认评分逻辑在漏选和多选场景下不会崩。def generate_fake_sheet(output_path: str, answer_key, rows: int, cols: int 5): 生成一张只有选项区域的模拟答题卡用于流水线自测 img_height rows * 50 40 img_width cols * 60 40 img np.full((img_height, img_width), 255, dtypeuint8) for i, ans in enumerate(answer_key): center_x 30 (ord(ans) - ord(A)) * 60 center_y 30 i * 50 cv2.circle(img, (center_x, center_y), 12, 0, -1) cv2.imwrite(output_path, img)把这张生成的图走完整个识别流程能验证切割网格和评分逻辑是否正确。如果模拟图都不能稳定识别问题一定在代码本身而不是拍摄环境。第二个习惯是每步都保存中间结果。调试阶段把灰度图、二值图、透视校正图、每个ROI的掩膜结果都输出到debug目录。二值化效果、轮廓框画得准不准看图一眼能定位比反复print坐标高效很多也不会在答辩演示时陷入“不知道哪一步出问题”的黑匣子状态。最后一个细节是评分函数里一定要保留marked原始标记。考试现场总会有擦不干净的铅痕如果在预处理阶段就把它们彻底清掉很容易误伤真实的浅涂答案。把“多选”和“未选”单独标记出来反而给复核留了余地。这个调整让我的演示从“被老师随机抽查一张就露馅”变成“抽查到了也能解释原因”。如果你也准备拿这个题目做毕业设计建议先把核心识别和数据库跑通再做界面和附加功能顺序别反了。希望帮到你。本文还有配套的精品资源点击获取