简介:该项目是一套基于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, key=cv2.contourArea, reverse=True)[: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), dtype="float32") s = pts.sum(axis=1) # 每个点的x+y diff = np.diff(pts, axis=1) # 每个点的y-x rect[0] = pts[np.argmin(s)] # 左上角:x+y最小 rect[2] = pts[np.argmax(s)] # 右下角:x+y最大 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]], dtype="float32") 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, questions=25, choices=4): # 统计每行的黑色像素数量,用于确定题目的行边界 row_projected = np.sum(warped_binary, axis=1) # 找出黑像素大于某阈值的行索引范围,形成若干连续区间 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, questions=25, choices=4): # 先做自适应阈值,避免全局阈值在光照不均时失效 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_q=1): # 逐题比对并计算总分 total = 0 detail = [] for qid, pred in enumerate(recognized_answers, start=1): 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还是1,correct_answer是纯字母还是带空格,都和读取逻辑强耦合。我拿到项目的第一步永远是打开Excel确认列名和首行数据,而不是直接跑代码。如果read_excel出KeyError,不要怀疑函数,先去改Excel列名统一成代码里的约定。
3.3 批量判分与Excel导出:excel.py 的导出结构
项目里的excel.py负责把判分结果写成新的Excel表。一张表包含题号、学生答案、正确答案、每题得分、总分——这个结构对老师来说是最直观的。用pandas.DataFrame构建结果,再调df.to_excel()导出。这里有个小细节:to_excel需要指定index=False,否则会把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': f'2024{i+1: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='总成绩', index=False) df_details.to_excel(writer, sheet_name='判定明细', index=False)导出这一步值得多花几分钟整理文件名规范。我一般用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-python==4.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="选择答题卡图片", command=self.open_file) btn_open.pack(pady=10) btn_run = tk.Button(self.root, text="开始识别", command=self.run_recognition) btn_run.pack(pady=5) 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"[{idx+1}/{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的答题卡识别系统,坑和尖子都在算法边界里,你把边界摸清楚,它就是很好用的工具。希望帮到你。
本文还有配套的精品资源,点击获取