
简介一套基于Python、OpenCV与PyQt开发的答题卡识别软件完整源码面向需要实现自动阅卷、减轻人工批改负担的开发者或高校学生。软件支持单选和多选题的智能识别内置定制化答题卡模板并能自动统计成绩、导出Excel报表同时提供辅助脚本与配置文件可直接运行或二次开发。压缩包共34个文件以Python源代码9个.py文件为主辅以编译生成的.pyc缓存、样式表.css、JSON配置、图标图片及Word说明文档等整体大小仅177KB结构紧凑、便于快速上手。目前已有6214人学习下载适合图像处理入门及课堂设计项目参考。通过源码可深入理解OpenCV图像预处理、轮廓检测、选项填涂判读等关键环节并借鉴PyQt界面设计与多线程处理思路是学习实战编程的优质素材。1. 这套源码到底在解决什么问题答题卡识别不是简单的“找涂黑的点”期末考完几百张答题卡摞在办公桌上对着答案卡手动判分眼睛快花了。如果有一台普通扫描仪或手机拍照跑一遍 PythonOpenCVPyQt 开发的答题卡识别软件就能自动把每张卡里涂黑的选项读出来、对照标准答案算分再在 PyQt 界面里点几个按钮查看结果——这就是标题里这套源码要做的事。它不是什么高深 AI核心是用 OpenCV 做一系列图像操作找答题卡边界、透视摆正、定位选项、判断涂卡再用 PyQt 把流程包装成一个能双击运行的桌面工具。适合培训机构老师、教务人员也适合拿 OpenCV 图像处理做课程设计或练习的开发者。真正做过的人会告诉你难点不在判断哪一格里黑而在怎么把答题卡从一张乱糟糟的桌面照片里稳稳抠出来。2. 先看清楚流程再写代码OpenCV 图像处理管线与 PyQt 的角色分工写代码之前我建议先把整条数据流画出来。你拿到的不只是一张图片而是一张经过光照、角度、分辨率三重污染的扫描件。图像处理的每一步都会把上一步的错误放大轮廓偏了几个像素透视变换后选项就错位选项错位后面的涂卡判定再准也没有意义。所以这一章先把架构讲清楚告诉你为什么这么选型以及源码工程应该怎么拆。2.1 为什么是 OpenCV PyQt 而不是纯 OpenCV 或 OCR 框架先回答一个最常见的疑问既然 OpenCV 就能处理图像为什么还要加 PyQt因为纯 OpenCV 脚本只有命令行输出适合开发者自己调试不适合给教务老师这种操作者用。他们需要的是打开窗口、选一张图片、点一个按钮、看到分数。PyQt 负责的就是这一层交互窗口、按钮、图片显示、结果表格。它的信号槽机制下面要说的 QThread 也是避免界面卡死的关键。另一个常见疑问是为什么不直接用 Tesseract 这类 OCR 框架因为答题卡识别判断的并不是“这个字符是什么”而是“这个格子有没有被涂黑”。OCR 是要把图像里的小方块、印刷体字母识别成文字用在答题卡上是杀鸡用牛刀而且它对倾斜、光照、铅笔灰度特别敏感。OpenCV 一个简单的 ROI 像素统计就能完成判断速度和稳定性都好得多。两者的边界要分清OCR 处理的是语义内容OpenCV 处理的是几何和灰度特征。PyQt 版本上我一般建议用 PyQt5。网上资料多遇到的 Bug 基本都有人踩过PyQt6 虽然新但部分 API 改名了比如执行环境和QAction相关的位置有变动新手容易被旧教程误导。如果你只是做答题卡这种桌面工具PyQt5 足够稳定。2.2 五步流程灰度、透视、定位、检测、判分把整套识别拆成五步每一步的输入输出都非常明确。下面这张表可以当作你写模块时的接口约定步骤输入输出核心操作预处理原始图像二值图灰度、高斯模糊、OTSU 阈值轮廓定位二值图卡片四角坐标findContours、approxPolyDP透视变换原图 四角坐标摆正后的卡片图getPerspectiveTransform、warpPerspective选项定位摆正图每个选项的 ROI 矩形网格划分或投影切分涂卡判定与评分ROI 列表 标准答案得分和每题明细像素占比阈值、答案映射预处理是为了让答题卡和背景分离得干净。轮廓定位是为了找到卡片这一个大四边形而不是卡上的选项小圆。透视变换解决手机拍照时卡片变形的问题。选项定位决定后续 ROI 的边界这步最影响准确率。涂卡判定反而是最简单的后面第 4 章会看到。这里要注意顺序不能反。有人喜欢先做 Canny 边缘检测再去找轮廓但 Canny 对噪声很敏感卡片上的印刷文字也会产生大量边缘反而干扰外轮廓。我个人更习惯先 OTSU 二值化再做轮廓因为答题卡通常是深色背景上的浅色纸张反转后卡片区域会形成一块连通的白色区域外轮廓非常干净。2.3 源码工程怎么组织一个文件跑通 vs 按模块拆分我见过不少开源项目把全部代码塞进一个 main.py几百行下来你要调阈值就得翻整个文件。常见做法是拆成三个模块职责分开后面替换某个环节不需要动其他地方。answer_sheet/ ├── main.py # 入口启动 PyQt 窗口 ├── image_processor.py # OpenCV 图像处理与识别 ├── answer_sheet.py # 标准答案读取、评分逻辑 ├── main_window.py # PyQt 界面调用上面两个模块 ├── resources/ │ └── sample_card.png # 测试用答题卡图片 └── requirements.txt # opencv-python, numpy, PyQt5image_processor.py只做图像相关的事读取图片、预处理、找轮廓、透视变换、网格切分最终返回一个“识别结果”字典。answer_sheet.py负责加载标准答案、计算分数。main_window.py是 UI 层把图片路径传给图像处理模块再把识别结果展示到表格里。我会先把image_processor.py单独跑通比如在脚本末尾加一段if __name__ __main__输入一张测试图片打印识别出来的选项列表确认数据正确后再套 PyQt。这种做法能大大减少界面调试的干扰。requirements.txt 里不用写死版本写opencv-python、numpy、PyQt5就行安装时让 pip 自己去解析。Python 版本建议 3.8 以上OpenCV 的 API 在这些版本上行为一致。2.4 先把中间结果保存出来识别率不稳的排查起点这一小节是我的个人习惯也是后面所有调试的基础。在你还没把界面做出来之前先让图像处理模块把每步的关键中间图写到磁盘原图缩小图、二值图、透视变换后的卡片图、每个选项 ROI 放大图。不要嫌麻烦第一次跑通时你会发现错误往往不在最后一步而在中间的某个环节。def save_intermediate(image, name, output_dirdebug): import os os.makedirs(output_dir, exist_okTrue) path os.path.join(output_dir, name) cv2.imwrite(path, image) print(f[debug] saved {path})调用策略很简单预处理后存01_binary.png透视后存02_warped.png网格切分后把每个 ROI 按题号拼成一整张大图存03_cells.png。看到哪一步图像不对就去改对应参数。这个习惯能让你从“改一个参数全盘重跑”变成“定位到具体环节”节省几个小时。后面的避坑章节里绝大多数问题都是这么定位出来的。3. 把答题卡“摆正”并抠出每一道题透视变换与轮廓定位这是整套源码里最值得花时间的地方。透视变换就是把一张拍歪了的答题卡变成正视图而它的前提是找到卡片四个角点。角点找得是否准确直接决定后面网格切分能不能对齐。这一章给的代码可以直接抄进你的image_processor.py。3.1 图像读取与预处理灰度、去噪、二值化的参数选择先说读取。手机照片动辄两三千像素宽直接处理会拖慢速度而且对找轮廓没有好处。我一般先把最长边缩到 1200 像素再往下走。扫描仪的图通常比较大但也是这个思路。import cv2 import numpy as np def load_image(path, max_side1200): img cv2.imread(path) if img is None: raise FileNotFoundError(f读不到图片: {path}) h, w img.shape[:2] scale max_side / max(h, w) if scale 1.0: img cv2.resize(img, (int(w * scale), int(h * scale))) return img def preprocess(img): gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray cv2.GaussianBlur(gray, (5, 5), 0) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) return gray, binaryload_image里用max_side而不是固定尺寸是为了保持长宽比避免卡片被拉伸。GaussianBlur的核大小 (5,5) 能滤掉扫描产生的孤立噪点如果卡纸纹路很重可以改成 (3,3)再大容易把浅色的选项边框也糊掉。cv2.threshold用 OTSU 时前面的阈值参数填 0 即可算法会根据直方图自动选分割点。THRESH_BINARY_INV是反转二值化原本黑色的涂卡笔迹变成白色白纸背景变黑。这样后面统计像素时直接数白色像素就行。这里有个容易被忽略的点OTSU 假设图像灰度直方图是双峰分布。答题卡放在深色桌面上时背景和卡片会形成两个峰效果很好但如果桌面颜色和卡片接近或者卡片上有大面积底纹OTSU 可能把整张卡判成背景。遇到这种情况不要急着改阈值先回到拍照端保证卡片和背景有对比度。3.2 找答题卡外轮廓并做透视变换approxPolyDP 与 warpPerspective 的配合预处理之后答题卡变成一张白色区域。接下来用findContours找外轮廓并按面积排序因为画面里最大的四边形通常就是答题卡。然后approxPolyDP把轮廓拟合成更少的多边形目标是正好得到 4 个顶点。def find_card_contour(binary): contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours sorted(contours, keycv2.contourArea, reverseTrue) for cnt in contours: peri cv2.arcLength(cnt, True) approx cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) 4: return approx.reshape(4, 2).astype(np.float32) raise ValueError(没有找到四边形的答题卡区域)RETR_EXTERNAL只取最外层轮廓避免卡上的选项圆框被当成独立轮廓。approxPolyDP的第二个参数是最大距离阈值一般取周长的 2%这个值是经验值。如果卡片边缘很平滑0.02 就够如果边缘有锯齿可以放宽到 0.03。但要小心放得太大轮廓会被压成三角形len(approx)就不是 4 了。找到四个角点后还要排序。cv2.findContours返回的角点顺序不确定而getPerspectiveTransform要求输入点和目标点按同一顺序对应否则透视结果会翻转。常见做法是按坐标的加减法排序def order_points(pts): rect np.zeros((4, 2), dtypenp.float32) s pts.sum(axis1) d np.diff(pts, axis1).flatten() rect[0] pts[np.argmin(s)] # 左上x y 最小 rect[2] pts[np.argmax(s)] # 右下x y 最大 rect[1] pts[np.argmin(d)] # 右上x - y 最小 rect[3] pts[np.argmax(d)] # 左下x - y 最大 return rect这个排序在卡片近似矩形时非常可靠。有了按序排好的四个点就能做透视变换了def four_point_transform(img, pts): rect order_points(pts) tl, tr, br, bl rect width_a np.linalg.norm(br - bl) width_b np.linalg.norm(tr - tl) height_a np.linalg.norm(tr - br) height_b np.linalg.norm(tl - bl) max_w int(max(width_a, width_b)) max_h int(max(height_a, height_b)) dst np.array([[0, 0], [max_w - 1, 0], [max_w - 1, max_h - 1], [0, max_h - 1]], dtypenp.float32) matrix cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(img, matrix, (max_w, max_h)) return warped目标点用四边形的最大宽度和最大高度是为了避免原图倾斜时某条边被压缩导致卡片变形。np.linalg.norm可以直接算两点距离比手写开根号清晰。warpPerspective的插值默认是线性插值对灰度图足够如果你后续要做精细的涂卡判断可以改cv2.INTER_CUBIC但速度会慢一点。透视变换做完后建议把save_intermediate(warped, 02_warped.png)存下来肉眼确认答题卡四条边是否平行于图像边缘。如果还是歪的大概率是角点排序出错或者approxPolyDP拟合到了卡片外的某个背景区域。3.3 按网格划分选项区域计算行高列宽的两种方式摆正后的答题卡已经是矩形接下来要把每个选项的位置算出来。常见做法有两种均分法和投影法。均分法适用于答题卡版式规整每行选项数固定、上下左右没有多余的留白投影法适用于卡片边缘有定位黑块、需要按内容自适应分割的情况。先讲均分法因为它最容易理解和复现。def split_grid(warped, rows, cols, padding5): h, w warped.shape[:2] cell_h h // rows cell_w w // cols cells [] for r in range(rows): for c in range(cols): x1 max(c * cell_w padding, 0) y1 max(r * cell_h padding, 0) x2 min((c 1) * cell_w - padding, w) y2 min((r 1) * cell_h - padding, h) cells.append((x1, y1, x2, y2)) return cellspadding5很关键。它把每个 ROI 向内缩 5 像素避免把选项框的印刷边框也算进去。边框本身是深色如果截图时包含它is_marked的黑色像素占比会偏高容易误判为涂卡。格子尺寸用整除最后一行或一列可能有几个像素误差但每个选项框远大于 5 像素这一点误差不影响。投影法是均分法的补充。把透视后的图按行做水平投影统计每一行黑色像素的数量相邻行之间形成“山谷”的地方就是不同题目的分界线。同理垂直投影可以分出选项 A、B、C、D。这个方法更智能但实现稍复杂需要对投影数组做平滑和峰值检测。如果答题卡是标准模板均分法就够用如果卡纸样式经常变投影法能少改代码。另外有些答题卡四角会印黑色定位块。如果你看到四个角有明显的方形黑块可以在找轮廓之前先用cv2.inRange把黑块单独提取出来再用findContours定位它们的中心作为透视变换的四个锚点。这样比最大轮廓法更抗干扰即使卡片外还有别的物体也能稳定找到。不过模板固定的话最大轮廓法最省事我一般先用它不行再上定位块方案。4. 从“哪个圈被涂了”到“这张卡得了多少分”涂卡判定与评分映射图像定位做完后剩下的事情就机械很多对每个 ROI 统计涂黑面积跟标准答案做映射算分数。这一章把判定阈值、答案格式和评分逻辑讲透。很多人在这里翻车常常是因为阈值设置不合里或者标准答案的索引没对齐。4.1 涂卡判定的核心指标像素点数、阈值与容错判断一个格子有没有被涂黑最直接的办法是统计 ROI 中黑色像素的比例。这里的“黑色”指的是预处理后留下的白色像素因为我们在前面做了THRESH_BINARY_INV。所以下面的函数里用 OTSU 再做一次局部二值化然后countNonZero数白点。def is_marked(gray, cell, ratio_thresh0.2, padding5): x1, y1, x2, y2 cell x1 padding y1 padding x2 - padding y2 - padding roi gray[y1:y2, x1:x2] if roi.size 0: return False _, binary cv2.threshold(roi, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) filled cv2.countNonZero(binary) return (filled / roi.size) ratio_threshratio_thresh0.2是我常用的起始值。如果是 2B 铅笔涂得比较满0.2 能稳定识别如果是 HB 铅笔涂得浅可能要降到 0.15如果墨迹重或者选项框周围有杂质要提高到 0.25。这个值的调节建议配合前面保存的 ROI 放大图来看不要盲目改。注意这里对每个 ROI 单独做了一次 OTSU而不是直接用整图的二值化结果。原因是整图的光照可能不均局部阈值能更好地适应每块区域的灰度分布。代价是每个格子多一次阈值计算但对单张答题卡来说完全可以忽略。还有一个提升鲁棒性的做法在统计之前用cv2.connectedComponentsWithStats找出白点连通域只保留面积大于某阈值的连通域。这能过滤掉扫描产生的孤立噪声点。比如一个 50×50 的格子如果只有 5 个散点面积达到 20%那很可能是纸屑或灰尘。算连通域后把“单个连通域面积”叠加效果比单纯数像素稳定。4.2 标准答案的组织方式JSON 还是文本文件标准答案建议用 JSON 存原因是结构清晰能同时表达单选题和多选题。文本文件虽然写起来方便但解析多选时容易出错。下面是一个例子{ options_per_question: 4, answers: [0, 2, 1, 3, [0, 2], 1], multiple_choice: [5] }这里我统一用 0-based 索引A 对应 0B 对应 1以此类推。第 5 题是多选题答案[0, 2]表示选 A 和 C。multiple_choice数组标记哪些题是多选。加载时最好把答案全部统一成列表方便评分逻辑处理。import json def load_answers(path): with open(path, r, encodingutf-8) as f: data json.load(f) options_per_question data.get(options_per_question, 4) multiple_set set(data.get(multiple_choice, [])) answers [] for i, ans in enumerate(data[answers]): if isinstance(ans, int): answers.append([ans]) elif isinstance(ans, list): answers.append(sorted(ans)) else: raise ValueError(f第 {i1} 题答案格式不正确) return answers, options_per_question排序sorted(ans)是为了后面比较时不依赖学生涂卡顺序。比如正确答案是 A 和 C学生涂了 C 和 A也应该判对。如果不排序直接比较[0,2]和[2,0]会不相等产生误判。这是一个非常隐蔽但常见的坑。4.3 评分计算与异常处理未涂、多涂、涂浅怎么扣评分函数接收三个东西灰度图、格子列表、标准答案。它要遍历每一道题找出学生涂了哪些选项再和答案比较。除了“正确/涂错”之外还要区分“未涂”和“多涂”这样在界面上才能给老师有用的反馈。def score_card(gray, cells, answers, options_per_question4, ratio_thresh0.2): score 0 details [] for q_idx, correct in enumerate(answers): selected [] base q_idx * options_per_question for opt in range(options_per_question): if is_marked(gray, cells[base opt], ratio_threshratio_thresh): selected.append(opt) if len(selected) 0: status 未涂 elif set(selected) set(correct): score 1 status 正确 elif len(selected) 1: status 多涂 str([x 1 for x in selected]) else: status 涂错 details.append({ 题号: q_idx 1, 识别: [x 1 for x in selected], 标准: [x 1 for x in correct], 状态: status }) return score, details这里用set(selected) set(correct)来判对已经考虑了顺序。但要注意如果是单选且学生涂了多个选项len(selected) 1会命中介于“多涂”分支不会进入“正确”分支因为set和len同时满足才能判对。逻辑顺序是先检查未涂再检查多涂最后检查涂错这样状态归类更精确。扣分规则没有写死。上面的代码只统计得分不扣负分。如果应用场景允许答错扣分可以在循环里加一句status 涂错时score - 1但最低分建议不要低于 0避免学生看到负成绩。多选是否给半分的逻辑也更复杂我建议先做全对才得分后面再根据需求扩展。这样评分模块的输出简单界面展示也直接。5. 避坑识别率不高、程序崩溃、界面卡死问题多半出在这五个地方这一章把我自己实际开发中踩过的坑集中写出来。每一条都按“现象 → 原因 → 解决”的顺序你可以直接对照排查。5.1 透视变换后仍有倾斜角点排序的玄学现象跑完four_point_transform后输出的卡片是平行四边形左侧高右侧低网格切分全部错位。原因order_points依赖“左上角的 xy 最小”这个假设但答题卡在照片里是大角度旋转或者卡片本身有圆角时四个角的 xy 排序可能被破坏。approxPolyDP拟合出的四个点顺序不固定一旦顺序错了透视矩阵就按错误对应关系生成图像自然歪了。解决不要只依赖 xy 排序。我现在的做法是拿到四个点后先计算它们围成的四边形的凸包排除凹四边形然后对每个点计算它到其他三个点的距离距离最长的那条线的两端就是一组对角点另外两个是另一组对角点。再根据两对对角线交点的位置区分左右。简化代码如下def smart_order(pts): pts pts.reshape(4, 2).astype(np.float32) # 找对角线距离最远的两个点是第一对对角 d np.linalg.norm(pts[:, None, :] - pts[None, :, :], axis2) i, j np.unravel_index(np.argmax(d), d.shape) diag1 (pts[i], pts[j]) remaining [pts[k] for k in range(4) if k not in (i, j)] diag2 (remaining[0], remaining[1]) # 用对角线交点判断顺序这里省略核心是不要把顺序固定成“左上右上右下左下” return np.array([diag1[0], diag2[0], diag1[1], diag2[1]])这个方法比单一排序更稳。如果你不想自己写也可以在找轮廓后输出approx的坐标调试大部分问题一眼就能看出来。5.2 cv2.error 与 OpenCV 版本不匹配安装问题现象运行代码时直接报cv2.error: OpenCV(4.4.0) C:\Users\AppData\Local\Temp\1\pip-req-build-...或者ModuleNotFoundError: No module named cv2。原因最常见的是电脑里有多个 Python 环境pip install opencv-python装到了 A 环境你运行脚本却用的是 B 环境。另外opencv-python和opencv-contrib-python是两个不同包同时装会互相覆盖文件导致cv2.findContours返回值数量在不同版本里不一致进而报错。解决在项目根目录建一个虚拟环境只在这个环境里装依赖再安装时不要同时装两个包。命令是python -m venv venv venv\Scripts\activate # Windows source venv/bin/activate # Linux/macOS pip install opencv-python opencv-contrib-python numpy PyQt5装完后用python -c import cv2; print(cv2.__version__)验证。如果是在 Windows 上还遇到 DLL 加载失败检查 Python 是 64 位还是 32 位OpenCV 官方包不支持 32 位解释器。网上那些 opencv 安装教程很少提这一点但踩过的人都知道这问题能卡一下午。5.3 二值化后文字和噪点成了“选项”预处理顺序的坑现象没涂卡的选项区域识别结果却判定为“已涂”而且多集中在有印刷文字的题目编号附近。原因预处理时先做了阈值后做滤波或者根本没做滤波。印刷体文字在二值化后变成一堆细碎的黑点再被GaussianBlur一放大就跟涂卡的笔画混在一起。另一个原因是答题卡底纹有浅色网格OTSU 把网格也当成了前景。解决严格按“先灰度 → 高斯模糊 → OTSU 阈值”的顺序来。阈值后再加一次形态学闭运算去掉孤立小点kernel np.ones((3, 3), np.uint8) binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)MORPH_CLOSE可以用 3×3 的核把邻近的白点连起来同时滤掉单个噪声点。注意核不能太大否则两个相邻选项会被连成一片导致多涂误判。如果你发现网格线还是干扰可以透视变换后在水平方向做投影把网格所在行的黑色像素分布单独剔除但那个逻辑比较繁琐非必要不用上。5.4 PyQt 界面卡死把识别扔进 QThread现象点击“开始识别”按钮后窗口立刻变成“未响应”标题栏转圈等几十秒才恢复期间连关闭按钮都点不了。原因OpenCV 的图像处理是 CPU 密集操作单张手机照片可能耗时 2~5 秒。如果这段代码直接写在按钮点击的槽函数里Qt 的主事件循环被阻塞它就无法处理重绘、鼠标点击等事件界面就像死了一样。解决把识别放到 QThread 里跑识别完成后通过信号把结果送回主线程。下面是最小可用的写法from PyQt5.QtCore import QThread, pyqtSignal class RecognizeThread(QThread): finished pyqtSignal(object) def __init__(self, image_path, parentNone): super().__init__(parent) self.image_path image_path def run(self): result recognize_and_score(self.image_path) self.finished.emit(result)在主窗口里点击按钮时只负责启动线程不要直接调用识别函数。线程结束后再在finished连接的槽函数里更新界面。记住一个原则工作线程里不能直接操作 UI 控件只能发信号。否则轻则警告重则崩溃。按钮在识别期间可以setEnabled(False)防止重复点击。5.5 扫描答题卡时背景亮度不均自适应阈值也不是万能的现象同一张答题卡左边区域识别正常右边大片题目全部判“未涂”或者反过来。单独看 ROI 时会发现一侧的笔画灰度很浅。原因扫描仪压边、桌面阴影、卡片边缘翘起都会造成亮度渐变。全局 OTSU 只找一个阈值左侧正常时右侧可能因为暗角而低于阈值涂卡笔迹没有跟背景分开。这时候你用自适应阈值cv2.adaptiveThreshold可能会改善但它在全白区域会放大噪点导致另一类误判。解决我一般先给灰度图做 CLAHE 对比度增强让局部光照更均匀然后再走正常流程。一行代码gray cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)).apply(gray)clipLimit控制对比度限制越大增强越强tileGridSize是分块大小8×8 对 A4 答题卡照片基本合适。做完 CLAHE 后再看 ROI铅笔笔迹的灰度值会比之前更稳定。如果仍然不行就需要对每个 ROI 单独计算阈值而不是用全图 OTSU。这个方案可以作为最后兜底因为它会慢一些但识别率往往能提高好几个百分点。6. 进阶把识别结果实时显示到界面上并用数据校验整体识别率识别逻辑稳定后最后一步是把结果可视化并验证整套系统的可用性。很多初学项目止步于“脚本能输出分数”但真正要落地你还需要让老师看到每道题的对错以及整体准确率在什么水平。6.1 PyQt 中显示带边框的答题卡在 PyQt 的标签里直接显示 OpenCV 图像需要做一次格式转换。cv2.imread读出来是 BGR而 Qt 的 QImage 要的是 RGB。转换后记得copy()避免 QPixmap 在图像数据释放后引用悬空内存。from PyQt5.QtGui import QImage, QPixmap def cv2_to_pixmap(cv_img): rgb cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB) h, w, ch rgb.shape bytes_per_line ch * w qimg QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) return QPixmap.fromImage(qimg.copy())如果你想在界面上同时显示“哪道题错了”可以在 OpenCV 图像上用cv2.rectangle把错误题号对应的选项框出来再转 QPixmap。红色框表示错题绿色框表示正确这样老师一眼就能看到问题分布比看表格更直观。要注意在QImage里修改原始图像数据是无效的所以要先在cv_img上画完框再转换。6.2 批量验证识别率不要用一两张图就相信识别率。我会准备至少几十张扫好的答题卡把每张的真实答案按文件名约定记录下来比如card_001_A.txt里存标准答案然后跑一遍批量脚本统计准确率。import glob correct_cards 0 total_cards 0 for img_path in sorted(glob.glob(samples/*.jpg)): txt_path img_path.replace(.jpg, .txt) with open(txt_path, r, encodingutf-8) as f: expected eval(f.read()) # 这里用 json 更规范 result recognize_and_score(img_path) total_cards 1 if result[score] expected: correct_cards 1 print(f卡片级准确率: {correct_cards / total_cards:.1%})如果要分析题目级错误就在score_card返回的details里统计。看是“未涂”多还是“多涂”多再回头调ratio_thresh。这一步很关键没有准确率数据的调参都是凭感觉。6.3 一个自适应阈值的经验公式最后分享一个我常用的经验公式当选项格子面积较小时阈值要适当提高因为像素总数少几个噪点就能把占比拉高。反过来格子大时阈值可以稍微降低。参考公式ratio_thresh min(0.25, max(0.15, 2000 / (cell_h * cell_w)))这个公式不是数学推导只是经验校正。对小格子它会在 0.2 左右浮动避免把边框噪声误判成涂卡。你可以根据你的卡纸尺寸微调上下限。我自己第一次做的版本就栽在角点排序上后来养成了把所有中间图保存下来逐个排查的习惯识别率才从 70% 拉到 95% 以上。这个方向值得做但别急着堆界面先把一张图的处理链路盯稳。希望帮到你。本文还有配套的精品资源点击获取