资讯动态

基于Python和OpenCV的智能答题卡识别系统实现

发布时间:2026/10/25 10:28:23 来源:尧图企业网站定制
简介该项目是一套基于Python与OpenCV的智能答题卡识别系统结合图像处理和深度学习算法面向计算机视觉学习者、高校学生以及需要实现自动阅卷功能的开发者适合课程设计、毕业设计或实际项目参考。系统覆盖答题卡检测、选项区域分割、选项识别、答案判定与分数统计完整流程代码按步骤组织便于理解与复现。压缩包共60个文件包含39张JPEG格式样本图像、8个XML标注与配置文件、5个Python源程序、1个Excel结果表格及说明文档总体积约38.86MB目录结构清晰能够按模块查阅。目前已有448人学习读者借助其中的图像数据集、源码和文档可快速上手掌握基于轮廓检测与卷积神经网络的答题卡识别思路代码中涉及边缘检测、透视变换、CNN分类等关键技术可供二次开发或论文复现。1. 基于Python和OpenCV的智能答题卡识别系统它到底替你完成了哪些活学校期中考试结束几位老师对着两百张答题卡手动判分一张卡二十五道题还要算总分、誊到Excel里折腾到晚上十点。我接手的问题很简单能不能让机器在十秒内完成一张卡的识别判分这套基于Python和OpenCV的智能答题卡识别系统给出的答案是“能”而且不需要昂贵的扫描仪和商用阅卷系统普通的手机拍照图、日常打印机出来的答题卡就能跑通。系统把图像识别算法的完整链路——答题卡定位、透视矫正、选项区域分割、涂卡判定、答案比对、分数计算、Excel导出——全部串联了起来附带源码、测试图片集和说明文档不依赖第三方商业SDK模型逻辑都写在Python脚本里。适合正在做图像处理课程设计的学生也适合想把简单OCR阅卷流程落地在自己电脑上的从业者。2. 从图纸到坐标答题卡识别管线的核心算法逻辑先把这套系统的处理管线捋清楚。它本质上是一条纯OpenCV的图像处理链路输入一张答题卡照片输出一份分数表。理解这条链路比直接看代码更省时间——因为图像识别项目里代码是算法的具象算法是代码的灵魂。管线顺序大致如下图像读取与预处理 → 答题卡区域检测 → 透视矫正 → 选项网格切分 → 涂卡判定 → 答案比对 → 分数输出。下面拆三个关键环节讲这三个环节基本决定了整个系统能不能跑通。2.1 答题卡定位边缘检测加轮廓筛选比你想的容易也脆弱答题卡在外观上有很强的先验特征矩形边框、浅色纸面、印刷的黑框。因此定位环节通常采用Canny边缘检测加轮廓筛选的策略而不是直接做深度网络目标检测。系统在get_answer.py中处理流程为先对灰度图做高斯模糊去掉噪点然后用Canny算子提取边缘接着调用cv2.findContours找出所有轮廓按轮廓面积排序取面积最大的四边形作为答题卡区域。这个策略在大多数环境光照正常、背景简单的场景下非常稳定但需要预先设定一个约束检测到的四边形必须近似是一个矩形即四个角点连线形成的轮廓拥有四个顶点。import cv2 import numpy as np def detect_answer_sheet(image_path): # 以灰度图读入强制转换为float避免后续计算溢出 img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊核大小设为(5,5)sigma标准差设为0由函数自动计算 blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny双阈值低阈值50高阈值150可根据图像对比度微调 edges cv2.Canny(blurred, 50, 150) # OpenCV 4.x返回两个值OpenCV 3.x返回三个值注意兼容性 contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 轮廓按面积从大到小排序答题卡在画面中通常占据最大面积 contours sorted(contours, keycv2.contourArea, reverseTrue)[:5] sheet_contour None for cnt in contours: # 先用多边形逼近epsilon是周长的一定比例控制逼近精度 peri cv2.arcLength(cnt, True) approx cv2.approxPolyDP(cnt, 0.02 * peri, True) # 答题卡区域必须是四边形顶点数等于4才进入候选 if len(approx) 4: sheet_contour approx break return img, gray, sheet_contour这段代码里两个参数最值得关注。cv2.Canny的双阈值需要根据实际拍摄条件调整光线均匀的扫描图可以适当提高低阈值到80去掉纸张纹理产生的微边缘手机随手拍的照片建议保留在50以下否则答题卡边缘可能断裂。0.02 * peri是轮廓逼近精度这个0.02是经验值——太小会导致逼近出来的多边形锯齿过多顶点数量超过四个太大会把直角压成斜边矩形轮廓丢失。我通常会在上位机上同时打印出检测到的轮廓顶点数和面积方便当场试错。2.2 透视校正四个顶点排序的数学细节和OpenCV实现答题卡定位成功之后最麻烦的问题随之而来摄像头视角往往不正拍出来的答题卡是倾斜的平行四边形不是矩形。直接在这种图上做网格切分必然失败。解决手段是透视变换先确定图像中答题卡四角的坐标再确定一个固定尺寸的目标矩形求解单应性变换矩阵把答题卡区域“矫正”成正视图。这一步的实现很依赖四个点的顺序约定。cv2.getPerspectiveTransform要求输入的四点与输出四点按相同顺序对应通常我按左上、右上、右下、左下排序。判断方法很朴素先求和横纵坐标和最小的点是左上先做差差值最小的点是右上其余两点根据坐标大小分配到右下和左下。def order_points(pts): # 初始化四个角点的坐标存储结构 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) # 每个点的xy diff np.diff(pts, axis1) # 每个点的y-x rect[0] pts[np.argmin(s)] # 左上角xy最小 rect[2] pts[np.argmax(s)] # 右下角xy最大 rect[1] pts[np.argmin(diff)] # 右上角y-x最小 rect[3] pts[np.argmax(diff)] # 左下角y-x最大 return rect def four_point_transform(image, pts): rect order_points(pts) (tl, tr, br, bl) rect # 分别计算上下底和左右边的最长距离作为目标矩形的宽和高 width_top np.linalg.norm(br - bl) width_bottom np.linalg.norm(tr - tl) max_width max(int(width_top), int(width_bottom)) height_left np.linalg.norm(tr - br) height_right np.linalg.norm(tl - bl) 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 warpedorder_points是整个透视矫正的关键一旦四个点的顺序排错矫正出来的图像会整体旋转90度或180度后续所有切分全部失效。项目文件里的warped.jpg就是矫正后的中间产物这也是我建议大家排查问题时必须保存的调试图——看到warped.jpg是正的矩形才继续往下跑。warpPerspective的插值方式默认是双线性处理答题卡这种线条精细的图像时够用如果换成立方插值cv2.INTER_CUBIC边缘更平滑但耗时增加批量处理时不太划算。2.3 选项区域切分按行列投影切格子的两种常见做法答题卡矫正成矩形之后接下来要把选项区域拆成独立的格子。常见做法有两种第一种是纯粹基于图像投影的自动切分把矫正图像二值化统计每一行、每一列的黑像素数量黑像素密集的区域就是题号行空隙就是边界以此切出格子。第二种是模板化切分提前在代码里定义好题目数量、每行选项数、起始坐标和偏移量直接按照坐标切片。这套系统的优势就在这里它结合了两种思路——通过投影找到题目区域的整体范围再用预设的行列数按网格切分而不是完全靠算法猜。def split_choices(warped_binary, questions25, choices4): # 统计每行的黑色像素数量用于确定题目的行边界 row_projected np.sum(warped_binary, axis1) # 找出黑像素大于某阈值的行索引范围形成若干连续区间 row_peaks [] start None for i, val in enumerate(row_projected): if val 0 and start is None: start i elif val 0 and start is not None: row_peaks.append((start, i)) start None # 说明这里每个row_peak区域就是一道题或一组题 # 实际项目中更稳妥的做法是按坐标比例切比例参数从模板里读 cell_h warped_binary.shape[0] // questions cell_w warped_binary.shape[1] // choices cells [] for q in range(questions): row_cells [] for c in range(choices): y_start q * cell_h y_end (q 1) * cell_h x_start c * cell_w x_end (c 1) * cell_w cell_crop warped_binary[y_start:y_end, x_start:x_end] row_cells.append(cell_crop) cells.append(row_cells) return cells这里有一个我必须强调的细节纯靠投影法做工程落地容错率并不高。答题卡印刷时的裁切偏移、学生手写笔迹越过边界都会让投影峰谷错乱。更好的做法是像上面代码展示的那样第一遍用投影定位大范围第二遍用等分网格方式切——因为正规印刷答题卡的格子间距是等宽的。如果切分结果出现了某行明显比别人的高或者矮先别怀疑算法去检查warped.jpg的矫正结果通常是透视变换时顶点坐标取到了答题卡外边框上的印刷标记。3. 涂卡判定和判分实现从像素统计到成绩表定位和切分做完系统的重头戏就来了判断每个格子里用户有没有涂卡以及涂的是哪个选项。这一步直接决定识别正确率。传统的图像处理方案里“涂没涂”通常用二值化后的黑色像素占比来衡量——格子区域内有明显的大面积黑色就认为是填涂过没有就认为是空白。这是一个像素统计问题准确率在光线稳定时能达到很高水准代价是几乎零成本不需要跑任何模型。3.1 涂卡判定用非零像素占比代替“肉眼观感”项目里的判定逻辑集中在对每个小格子的二值化图像做cv2.countNonZero统计计算黑色像素在格子总面积中的占比并和经验阈值比较。这个阈值通常设在0.3左右但我更建议做成可配置常量因为不同纸张的反光特性、不同打印机的墨粉浓度都会改变这个数值。如果阈值过低铅笔擦不干净的残留会被误判成涂卡如果阈值过高2B铅笔涂得轻一点的格子会被漏判。import cv2 import numpy as np # 判定阈值黑色像素占比超过30%认为是有效填涂 MARK_THRESHOLD 0.3 def is_marked(cell_binary): # cell_binary已经是二值图黑色为0白色为255 # 非零像素是白色部分所以先用逻辑取反再统计黑色 total cell_binary.shape[0] * cell_binary.shape[1] black_pixels total - cv2.countNonZero(cell_binary) ratio black_pixels / total return ratio MARK_THRESHOLD def extract_answers(warped_gray, questions25, choices4): # 先做自适应阈值避免全局阈值在光照不均时失效 binary cv2.adaptiveThreshold(warped_gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) cells split_choices(binary, questions, choices) answers [] for q_row in cells: mark_ratios [] for cell in q_row: total cell.shape[0] * cell.shape[1] black_ratio (total - cv2.countNonZero(cell)) / total mark_ratios.append(black_ratio) # 取占比最大的选项索引作为最终答案若最大占比不达标则记为空 max_idx np.argmax(mark_ratios) if mark_ratios[max_idx] MARK_THRESHOLD: answers.append(chr(ord(A) max_idx)) else: answers.append() return answers很多人第一次接触这段逻辑会问为什么不用连通域检测找“最黑”的区块而是单纯看像素占比我的回答是像素占比在答题卡场景下就够了。涂卡铅笔留下的石墨区是密集块状不是细线而印刷在格子里的答案是细瘦字体占空比通常在百分之十以内两者在阈值两侧有清晰的分界。但要注意adaptiveThreshold的块大小11会被纸张纹理影响如果你在处理扫描仪的300dpi高清图块大小可以放大到21以上它处理的是局部邻域的明暗分布比全局阈值抗光照不均的能力强不少。3.2 答案比对与计分data.xls 的格式约定识别完选项下一步是比对标准答案。项目内的data.xls存储标准答案表每行表示一道题目列表示正确答案的字母。读取Excel用pandas.read_excel因为免安装、交互友好也便于后期增加班级、学号等字段。比对逻辑非常简单系统识别出的选项字符串和标准答案字符串逐题比对匹配即得分。比较容易被忽略的是分数权重——不同试卷的单选题分值不同有的一题一分有的一题两分。通常会把这个分值维护在配置函数里而不是写死在代码中。import pandas as pd def read_answer_key(xls_path): # 读取标准答案表假设列为 question_id, correct_answer df pd.read_excel(xls_path) # 转为字典方便按题号索引 answer_key dict(zip(df[question_id], df[correct_answer])) return answer_key def grade_paper(recognized_answers, answer_key, score_per_q1): # 逐题比对并计算总分 total 0 detail [] for qid, pred in enumerate(recognized_answers, start1): correct answer_key.get(qid, ) # 大小写统一避免因字母大小写导致的误判 if pred.strip().upper() correct.strip().upper(): total score_per_q detail.append({question: qid, predicted: pred, correct: correct, score: score_per_q if pred correct else 0}) return total, detail这个模块真正的坑不在比对而在答案文件的结构约定。data.xls里question_id的起始编号是0还是1correct_answer是纯字母还是带空格都和读取逻辑强耦合。我拿到项目的第一步永远是打开Excel确认列名和首行数据而不是直接跑代码。如果read_excel出KeyError不要怀疑函数先去改Excel列名统一成代码里的约定。3.3 批量判分与Excel导出excel.py 的导出结构项目里的excel.py负责把判分结果写成新的Excel表。一张表包含题号、学生答案、正确答案、每题得分、总分——这个结构对老师来说是最直观的。用pandas.DataFrame构建结果再调df.to_excel()导出。这里有个小细节to_excel需要指定indexFalse否则会把DataFrame的行索引写进第一列导出的表会多一列没意义的数字。同样批量处理时建议把总分放在最后一列而不是最前面这样老师用Excel筛选时不容易误触列结构。import pandas as pd def export_result(output_path, scores, details): # scores是每个学生的总分列表details是每题的具体判定记录 records [] for i, total in enumerate(scores): # 这个位置一般还会拼上学生的学号来源是id.jpg的识别结果 records.append({ student_id: f2024{i1:03d}, total_score: total, }) df_scores pd.DataFrame(records) df_details pd.DataFrame(details) # 使用ExcelWriter可以在一个文件里写多个sheet with pd.ExcelWriter(output_path) as writer: df_scores.to_excel(writer, sheet_name总成绩, indexFalse) df_details.to_excel(writer, sheet_name判定明细, indexFalse)导出这一步值得多花几分钟整理文件名规范。我一般用batch_xxx_results.xlsx的命名加上时间戳避免批量跑多轮时旧结果被覆盖。excel.py在项目里本身就是独立模块它不关心上游识别用的是OpenCV还是深度学习模型外部只需要传入一个内容约定好的数据结构。这样做的好处是解耦等到将来想升级识别模块不需要动Excel导出的任何代码。4. 避坑指南答题卡图像识别最容易翻车的五个位置这个项目我实际跑过之后最深的体会是不是算法设计难而是实际拍摄的图片质量带来的坑多到超乎想象。以下五条踩坑记录都是我在复现和调试过程中反复遇到并解决的按“现象 → 原因 → 解决”的方式列清楚。4.1 整张答题卡区域出现大量黑色块定位失败现象findContours后最大轮廓不是答题卡矩形而是一坨黑色不规则区域透视变换结果完全错乱。原因拍摄环境光照不均答题卡上出现大面积反光或阴影Canny边缘检测把阴影边界当成了轮廓。解决在预处理阶段加入cv2.equalizeHist做直方图均衡化或者改用cv2.adaptiveThreshold代替固定阈值二值化后再找边缘。同时限制轮廓筛选条件轮廓必须近似凸四边形且面积与图像总面积的比例在20%到80%之间。4.2 透视矫正后图像上下颠倒切分全乱现象warped.jpg是倒着的选项区域切分出来的格子与正常方向完全相反。原因order_points函数对四角排序的坐标约定依赖矩形位置如果答题卡在图像中是倒置摆放的按坐标求和、求差排出的左上角实际是视觉上的右下角。解决不要在order_points里用全局坐标排序改用计算四个角点之间距离的方式——距离最远的两个点是长短对角线端点再用质心位置区分上下左右。如果仍然颠倒在配置里增加一个rotate_admin手动修正开关工程上这叫“统治级解决方案”。4.3 铅笔涂卡被漏判黑色像素占比总差一点现象答题卡在扫描仪下正常换成手机拍完原本涂满的格子判定为空。原因2B铅笔的石墨在手机摄像头的白平衡下反光灰色像素没有被二值化归入黑色类。解决不要用全局阈值把灰度图硬转成二值图改用自适应阈值或HSV色彩空间分离。具体做法是把灰度图cv2.cvtColor转到HSV取V通道再用一个较宽容的阈值如60作为黑与灰的分界。涂卡判定阈值对应降为0.25。4.4 OpenCV版本差异导致代码直接报错现象在Ubuntu上安装的OpenCV环境跑findContours报ValueError: not enough values to unpack。原因OpenCV 3.x中findContours返回(image, contours, hierarchy)三个值OpenCV 4.x返回(contours, hierarchy)两个值。项目源码基于其中一个版本编写跨版本运行时解包错误。解决统一按contours, _ cv2.findContours(...)写法兼容4.x如果用3.x改成_, contours, _ ...。更彻底的办法是在requirements.txt里固定版本基于Python 3.8的虚拟环境直接用pip install opencv-python4.5.5.64一行命令避免后续环境差异。4.5 识别答案与真实涂卡错位一整行现象所有题目识别结果刚好比真实答案向后推移了一题第一题空第二十五题多出来。原因网格切分时一个没涂卡的空行被当成了题目行导致整体索引偏移。常见于试卷顶部有姓名班级填写区域投影分割时黑像素连续区间合并了不该合并的行。解决网格切分不要用连续黑色像素区间找题目行改用固定坐标比例——直接从矫正图顶部按预设偏移跳过姓名栏区域再按题目数量等分。这道工序没有捷径必须对照真实的打印版量出各区域坐标。5. 从脚本到可交付GUI封装、学号识别和批处理扩展get_answer.py跑通单张图片之后下一步是把它变成别人也能用的东西。项目里turntogui.py正好是这个角色它用Tkinter把识别流程包成一个桌面GUI选择图片、点击识别、展示结果、导出Excel。GUI的代码架构不算复杂但它暴露了传统脚本往软件形态演变时要处理的那些边界问题文件路径弹窗、异常捕获、进度条反馈。5.1 GUI封装:按钮后面依然是一条纯函数链路turntogui.py的核心结构是把get_answer.py里所有识别逻辑包成一个recognize_single_image(image_path)函数GUI只负责调函数和展示结果。我比较认可这种分离方式——识别逻辑不依赖GUI框架后续在命令行批处理时可以直接复用。GUI层的代码大约有这些关键节点一个按钮绑定filedialog.askopenfilename选择图片调用识别函数时用threading.Thread把耗时操作放到子线程防止界面卡死结果显示区域展示warped.jpg中间图和最终分数。import tkinter as tk from tkinter import filedialog, messagebox from get_answer import recognize_single_image class AnswerSheetGUI: def __init__(self): self.root tk.Tk() self.root.title(答题卡识别-OpenCV版本) self.img_path None btn_open tk.Button(self.root, text选择答题卡图片, commandself.open_file) btn_open.pack(pady10) btn_run tk.Button(self.root, text开始识别, commandself.run_recognition) btn_run.pack(pady5) def open_file(self): # 限定图片格式避免用户选入非图片文件导致异常 self.img_path filedialog.askopenfilename( filetypes[(Image files, *.jpg *.jpeg *.png *.bmp)]) def run_recognition(self): if not self.img_path: messagebox.showwarning(提示, 请先选择答题卡图片) return try: total, details recognize_single_image(self.img_path) messagebox.showinfo(识别结果, f当前试卷总分: {total}) except Exception as e: messagebox.showerror(错误, str(e))GUI版本在实际使用中最大价值是让非技术用户不用碰命令行。但我也要提醒Tkinter的mainloop是阻塞式的识别期间如果直接在主线程跑OpenCV图像处理UI会显示无响应。上面代码中我用了子线程的思路但实际工程里还要配合一个Queue把识别结果传回主线程更新界面否则Tkinter非线程安全的特性会引发随机闪退。5.2 学号信息位识别id.jpg 对应的数字区域项目中有一张id.jpg这是答题卡上的准考证号或学号区域。传统OpenCV方案识别数字的思路是先按轮廓切出每一个数字格子再和印刷数字模板做匹配。这里不需要上深度学习因为学号数字都是标准印刷体形状规整模板匹配的准确率在干净图上足够高。模板匹配做法是把数字区域灰度化二值化后按连通域切出单字符与预制模板库里的0-9.png逐个算matchTemplate取相关系数最高的作为结果。这个思路在扫描仪图上基本不翻车难点依然是切分——如果学号格子的分隔线被手写笔迹覆盖连通域就会粘连最终只切出半个字符。遇到这种图直接走手动输入学号的兜底方案反而更稳妥。5.3 批量文件夹扫描把单张识别扩成整批阅卷实际阅卷场景没有一张张点开识别的耐心所以批处理扩展几乎必然要做。思路也不复杂用os.listdir遍历指定目录下的图片文件逐个调用recognize_single_image最后把结果汇总到同一个Excel里。这里要注意两个实际问题其一不同图片的分辨率、方向可能不同建议批处理前置一个文件名命名规范比如班级_学号.jpg直接从命名里提取学生信息而不是依赖id.jpg的识别其二识别失败的图片要单独记录不能影响整个批次。项目里的getall相关文件表明它已经具备批量意图但工程化程度通常停留在脚本调用层面。import os import glob def batch_recognize(image_dir, result_excel_path): # 匹配常见的答题卡图片格式按文件名排序保证顺序稳定 image_files sorted(glob.glob(os.path.join(image_dir, *.jpg))) all_records [] failed_files [] for idx, img_path in enumerate(image_files): try: total, detail recognize_single_image(img_path) # 从文件名中提取学生ID例如 IMG_20241012_张三.jpg student_id os.path.basename(img_path).split(_)[-1].split(.)[0] all_records.append({file: img_path, student_id: student_id, total: total, detail: detail}) print(f[{idx1}/{len(image_files)}] {student_id}: {total}分) except Exception as e: failed_files.append(img_path) print(f[失败] {img_path}: {e}) # 所有成功识别的结果统一导出 export_result(result_excel_path, all_records) print(f完成成功 {len(all_records)} 张失败 {len(failed_files)} 张)批处理代码里的failed_files列表是血泪经验的产物。第一次做批处理时我没有这一行结果识别到第50张图片时报错退出前面49张的结果全部丢失因为异常中断了整个脚本。从那以后我凡是写批处理一律坚持“失败文件单独登记循环不中断”的写法这个习惯帮我省掉了大量返工时间。6. 最后一步自建样本压测和阈值参数记录你拿到这套源码之后最有价值的参考动作不是直接拿真实试卷跑而是先建一套自己的压测样本集。我的做法是打印三张空白答题卡分别用四种方式填涂第一张全部用2B铅笔填涂规范第二张用力轻涂第三张用黑色签字笔涂第四张故意涂错几题并留下橡皮擦痕迹。这四种样本基本覆盖了日常使用场景的绝大多数变体。然后跑一遍系统检查每一张图的输出与人工判定的一致性。压测时我会特别关注两个指标一是涂卡判定阈值取多少才能让四张样本都判对二是光线变化时哪张样本先崩。经验值是这样的在室内普通LED灯照明下四张样本的黑色像素占比稳定落在0.35到0.75之间阈值取0.3可以留足余量但到了窗边自然光场景轻涂样本的占比可能掉到0.22此时阈值必须跟着下调到0.2。这就是为什么我反复强调阈值不能写死在代码里而务必要放到配置模块或者命令行参数上。每换一个拍摄环境调一次阈值并记录到一份params_log.txt比事后猜参数高效得多。验证方式也有讲究别只看总分对不对要把每题的判定明细打印出来逐行对照。总分一致但内部错了四题的情况经常出现——因为涂卡算法可能在一道题上误判为空、另一道题上误判为已涂总分恰好抵消。只有明细级核对才能真正发现系统边界。最后说一个我自己的习惯。以前我改完图像处理代码总爱拿着好消息图片反复测测两遍过了就算完工。结果经常是上线之后换一张光线差一点的图就翻车。现在我不论改了什么参数或算法都会在手边备一张“坏样本”——一张刻意拍歪、光线不足、有反光的失败图每次修改都先跑它一遍它不通过就继续调。这个习惯让我的交付从“跑通一张图”变成了“跑通一类场景”。这套基于Python和OpenCV的答题卡识别系统坑和尖子都在算法边界里你把边界摸清楚它就是很好用的工具。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑