
简介一份基于Python的答题卡检测与选择题自动批改项目源码包覆盖试题切分、学生考号识别、填涂区域定位与选项比对等完整流程适合作为计算机、数学、电子信息类专业毕业设计、课程设计或算法实战的参考项目。压缩包共三十五个文件主体为八个Python脚本和两个Jupyter Notebook演示配套多张JPG与PNG样例图、环境配置文件、Dockerfile及使用说明总大小约六点零四MB。目前已有二百三十七人浏览学习。代码模块拆分较细主程序、工具函数、独立测试脚本相互分离支持直接运行说明文档和容器配置可帮助快速搭建运行环境。读者既能借助样例图片直观理解答题卡切割、考号识别、选项检测的每一步效果也能基于现有结构二次开发是入门图像处理与OCR识别方向可参考的完整工程。1. 一套Python流水线把读卡到出分压到几十毫秒批改一张答题卡多数人以为难点在认ABCD实际干过的人都知道难点在定位。这套基于python的答题卡检测、试题切分、学生考号识别、选择题自动批改方案就是一条把定位、切分、判读串起来的流水线输入一张扫描图或拍照图输出Excel成绩表单张出分通常在几十毫秒量级。它适合三类人课业繁重想省掉手工阅卷的老师做教育信息化系统的开发者拿OpenCV练实战的Python入门者。这类以“新版源码说明”命名的压缩包并不少见跑通demo不难难的是换一批真实扫描图之后参数要不要重调、哪些环节会先翻车——后面就从检测到批改逐段拆开说。2. 答题卡检测与定位Canny边缘、霍夫直线与透视矫正的配合答题卡检测的输入通常是手机拍照或扫描仪输出光照不均、角度倾斜、桌面背景杂乱三样占全。检测的目标不是把整张纸框住而是拿到四个角点和版心区域后面所有切分都建立在这两个坐标之上。我一般把检测拆成三级边缘、直线、角点对应OpenCV里的Canny、HoughLinesP和approxPolyDP。这套组合很老但在答题卡这种规则几何版面上比深度学习方案更稳也更容易调参。2.1 为什么选Canny加霍夫直线而不是直接上深度学习答题卡版面是高度规则的几何图形边框线、题号区、选项格都是直线和矩形。对这种结构传统CV的确定性远好于模型Canny给边缘图霍夫直线给线段线段求交给角点每个环节的输出都能可视化检查。深度学习的版面分析模型不是不能用但要标注、要训练、要照顾推理速度为一个规则版面投入这些成本不划算。常见做法是先用传统方法跑通样本里混入极端角度或模糊图时再考虑用分割模型兜底。import cv2 import numpy as np def detect_card_edges(image_path): # 读入并转灰度 img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊去噪5x5核是扫描图的常见起点 blur cv2.GaussianBlur(gray, (5, 5), 0) # Canny边缘检测低阈值50高阈值150 edges cv2.Canny(blur, 50, 150) # 霍夫直线检测threshold80控制投票数 lines cv2.HoughLinesP(edges, 1, np.pi / 180, 80, minLineLength30, maxLineGap10) return img, edges, lines逻辑说明函数先把彩色图降成灰度用高斯模糊把纸张纹理和噪点压掉再做Canny。Canny的两个阈值决定边缘敏感度高阈值150把梯度弱的噪声边缘滤掉低阈值50用来连接断裂的强边缘。霍夫直线在参数空间投票threshold80表示一条直线至少需要80个边缘点投票才被接受minLineLength30是线段最短长度maxLineGap10是同一线段上允许的最大断裂间距。参数说明扫描图通常把threshold设在60到100之间minLineLength设在30到50。实拍图背景杂乱时调大threshold减少误检边缘断裂严重时调大maxLineGap。输出lines是一组线段下一步从线段求交点。2.2 透视矫正把斜着拍的答题卡拉正拿到线段之后下一步是求四边交点得角点。答题卡上边缘线不一定能闭合成矩形常见做法是对线段按角度聚类成横竖两组分别求延长线交点再用approxPolyDP从交点集合里逼近四边形。这一步是整条流水线的命脉透视矫正做歪了后面切格、判读全错位属于典型的牵一发而动全身。def order_points(pts): # 对四个角点排序左上、右上、右下、左下 rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) d np.diff(pts, axis1).flatten() rect[0] pts[np.argmin(s)] # 坐标和最小的是左上 rect[2] pts[np.argmax(s)] # 坐标和最大的是右下 rect[1] pts[np.argmin(d)] # 坐标差最小的是右上 rect[3] pts[np.argmax(d)] # 坐标差最大的是左下 return rect def four_point_transform(image, pts): rect order_points(pts) (tl, tr, br, bl) rect # 用四边长度计算目标矩形宽高避免固定比例拉伸 w1 np.linalg.norm(br - bl) w2 np.linalg.norm(tr - tl) h1 np.linalg.norm(tr - br) h2 np.linalg.norm(tl - bl) max_w max(int(w1), int(w2)) max_h max(int(h1), int(h2)) dst np.array([[0, 0], [max_w - 1, 0], [max_w - 1, max_h - 1], [0, max_h - 1]], dtypefloat32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (max_w, max_h)) return warped逻辑说明透视矫正的核心是单应矩阵getPerspectiveTransform用两组对应点求出8参数的单应矩阵warpPerspective把整张图映射到目标平面。角点排序不能错否则矫正结果会旋转或镜像order_points用“坐标和”与“坐标差”两个特征完成排序这是经典写法。参数说明目标矩形用检测到的四边长度取最大值保证答题卡内容不被压缩如果固定用A4纸比例碰到非A4版式的卡片就会拉伸。血泪经验拍照倾斜超过15度时单应变换外推误差会被放大矫正后选项格位置仍可能偏移一到两个像素这时不要硬调透视参数先回看角点检测是否把背景物体的边角也算进去了。2.3 答题卡ROI切分先定版心再切网格不要全图投影矫正后的图像就可以切分了。很多新手直接对整张二值图做水平投影找行结果把桌面噪点和纸张阴影也切进来。我一般会先找版心所有选项格轮廓的外接矩形的并集就是涂卡区域的范围。这个范围内再做行列网格切分可靠性高很多。def locate_marking_area(binary, min_area50): # 找所有涂卡格轮廓计算联合外接矩形作为版心 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) xs, ys, ws, hs [], [], [], [] for c in contours: x, y, w, h cv2.boundingRect(c) if w * h min_area: # 过滤小噪点轮廓 continue xs.append(x); ys.append(y) ws.append(w); hs.append(h) x0, y0 min(xs), min(ys) x1 max(x w for x, w in zip(xs, ws)) y1 max(y h for y, h in zip(ys, hs)) return (x0, y0, x1 - x0, y1 - y0)逻辑说明RETR_EXTERNAL只取最外层轮廓避免答题卡里印刷的题号数字产生内层轮廓干扰min_area50过滤掉面积小于50像素的孤立点。返回的四元组就是版心后面切题号区、选项区都以它为坐标基准。参数说明切网格时行高列宽用版心高度除以设定行数得到不要用固定像素——不同扫描分辨率下固定像素会直接切偏。涂卡格轮廓的数量可以帮你反推版式50道题的ABCD四选一卡理想状态下轮廓数量接近题目数若数量差太多优先怀疑二值化阈值而不是切分逻辑。3. 试题切分与学生考号识别投影切分与模板匹配的实操参数检测定位做完流水线进入第二个环节把答题卡切成题目块再把考生填涂的考号翻译成数字。这两个环节的输入都是二值图输出是题目坐标列表和考号字符串。很多源码包在这里会把步骤混在一起做我建议拆开切分负责给位置识别负责给结果分开调参才不会互相干扰。3.1 试题切分水平投影切行与轮廓切块怎么配合试题区常见的排布是一行一题左侧题号右侧ABCD四个格。水平投影把每行黑色像素数累加行与行之间间隙对应的投影值接近0折线图的波谷就是行边界。这个方法对印刷体版面非常稳但对跨行题干会出错——一行题目的题干太长折成两行投影里就多出一个波谷把同一题切成两段。def split_rows(binary, row_gap_thresh10): # 水平投影统计每一行的黑色像素总数 h, w binary.shape row_proj np.sum(binary 0, axis1) rows [] in_row False start 0 for i in range(h): if row_proj[i] 0 and not in_row: start i in_row True elif row_proj[i] 0 and in_row: if i - start row_gap_thresh: # 过滤高度过小的噪声行 rows.append((start, i)) in_row False return rows逻辑说明先把图像按“黑色像素为0”的约定二值化对每一行求和得到投影向量遍历投影值从有到无的过程记录行边界。row_gap_thresh10表示行高低于10像素的条带直接丢弃避免把印刷体文字的断笔当成整行。参数说明这个阈值按分辨率调整300DPI扫描图可以提到20手机拍照图保持10左右。跨行题干的处理常见做法是先按投影切出大段落再对段落内做垂直投影细切两层切分比一层更稳。切分出的行还要和二值图里的选项格坐标做交叉验证行边界必须落在格子的外接矩形间隙内否则说明投影受到了干扰。3.2 考号识别模板匹配的写法与适用边界考号是印刷体数字字体固定、位置固定是模板匹配的典型场景。常见做法是准备0到9共10张标准数字小图对考号区域的每个数字格子做matchTemplate得分最高的模板作为识别结果。这套方案成本低、速度快但血泪经验是模板必须和实际打印的字体同源同一个识别模板换成另一台打印机的样张匹配分数会掉到阈值以下。def match_digit(roi_gray, templates): # roi_gray是单个数字区域的灰度图templates是0-9模板字典 best_digit, best_score -1, -1.0 for digit, tmpl in templates.items(): th, tw tmpl.shape roi_resized cv2.resize(roi_gray, (tw, th)) res cv2.matchTemplate(roi_resized, tmpl, cv2.TM_CCOEFF_NORMED) _, score, _, _ cv2.minMaxLoc(res) if score best_score: best_score score best_digit digit return best_digit, best_score逻辑说明matchTemplate在roi内滑动计算归一化相关系数分数越接近1模板越匹配先要把roi缩放到与模板同尺寸避免比例不一致导致分数全低。识别完成后用best_score做质量控制常见做法是0.8以上直接接受0.6到0.8之间标记待人工复核低于0.6判为未涂或识别失败。参数说明别想着用单一阈值吃下所有扫描仪不同分辨率下同一张卡分数能差0.1。模板图片建议从实际答题卡样张里截取而不是用画图工具手绘手绘模板的分数会普遍偏低。3.3 考号判空与去噪开运算必须放在识别前考号区域最常见的干扰不是数字本身而是没擦干净的铅笔印、橡皮屑和扫描噪声。这些孤立点在二值图上和数字一样是黑色不处理就会识别出莫名其妙的数字。形态学开运算是先腐蚀后膨胀可以把小于结构元素的孤立点去掉同时保留数字主体形状。顺序不能反先开运算去噪再做模板匹配噪点少了匹配分数整体会往上走。def denoise_roi(roi_bin, kernel_size5): # 开运算先腐蚀去孤点再膨胀恢复主体 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_size, kernel_size)) opened cv2.morphologyEx(roi_bin, cv2.MORPH_OPEN, kernel, iterations1) # 判空开运算后黑色像素占比过低视为未填涂 h, w opened.shape ratio np.sum(opened 0) / (h * w) return opened, ratio逻辑说明kernel_size5在300DPI下对应约0.4毫米刚好小于印刷体数字的笔划宽度又大于孤立噪点把kernel改到7会开始削数字笔划改到3可能滤不掉噪声。iterations1就够了连续做两次开运算会把细的数字“1”也消掉。ratio阈值常取0.1低于0.1判空不进入模板匹配直接记为空考号。4. 选择题自动批改涂卡判读、答案对齐与成绩统计的三步实现批改是整条流水线的终点也是参数最“玄学”的地方。同样一张卡把判读阈值从30%改成25%得分可能差好几分。这一章把批改拆成三步判读每个格子的填涂状态、把填涂状态按答题卡版式对齐到题号、再把标准答案套进去算分。每一步都做成独立函数后面调参时才不用从头跑。4.1 涂卡判读黑色像素占比与连通域面积怎么配合判读一个格子里有没有被填涂最简单的是黑色像素占比黑色像素数除以格子面积超过阈值判为已涂。这个指标快但铅笔深浅直接影响结果涂得浅时黑色像素少占比会跌到阈值以下。更稳的写法是双重判定占比为主辅以连通域面积格子里存在一个面积足够大的连通域就认为是有效涂卡避免浅涂被漏判。def is_marked(cell_bin, ratio_thresh0.3, area_thresh20): h, w cell_bin.shape black_pixels np.sum(cell_bin 0) ratio black_pixels / (h * w) if ratio ratio_thresh: return True, ratio # 占比不足时检查最大连通域面积 num_labels, labels, stats, _ cv2.connectedComponentsWithStats( cell_bin 0, connectivity8) if num_labels 1: area max(stats[1:, cv2.CC_STAT_AREA]) if area area_thresh: return True, ratio return False, ratio逻辑说明先用占比快速通过不通过再看连通域这样浅涂的铅笔印虽然像素少但主体笔划会连成一片形成大连通域。connectivity8表示八个方向都算相连适合铅笔笔划的接触情况area_thresh20是像素面积下限扫描分辨率变高时要同比放大比如300DPI下可以给到50。参数说明注意多选规则要在这里处理。同一题涂了两个以上选项判读逻辑要返回一个“多涂”状态由上层按考试规则决定是给0分还是提示异常不要默默只取第一个选项。提示判读参数改动后强烈建议保留每张卡的格子像素占比到日志文件方便后面根据错题分布反向调参。4.2 答案对齐与计分二维矩阵比对比一维循环稳判读得到的是每个格子的状态还缺一层映射格子坐标对应哪一题哪个选项。最直接的办法是从版心坐标按行列索引推出题号把标准答案也整理成同样的行列二维表再做逐格比对。这样即使答题卡版式里混入了题号区也不会因为一维索引错位而连环错。常见做法是解析标准答案字符串时先按题号排序再按答题卡行数列表成二维表。def score_sheet(student_map, answer_map): # student_map和answer_map都是二维表形状与版心行列一致 total 0 wrong_list [] for r in range(len(answer_map)): for c in range(len(answer_map[r])): ans answer_map[r][c] stu student_map[r][c] if ans 0: # 0表示该位置不是选项格跳过 continue if stu ans: total 1 else: wrong_list.append((r, c, ans, stu)) return total, wrong_list逻辑说明答案用数字编码比如0跳过、1到4对应A到D避免用字符比较时出现大小写问题。wrong_list记录错题的行列坐标、正确答案和学生答案是后面做错题统计的唯一数据源。参数说明对齐的关键在看行列数是否和版心一致不一致时优先检查切分环节的行列参数而不是改这里的比对逻辑。行列数对不上还继续跑结果基本是整片错位成绩表没法用。4.3 输出Excel成绩表把得分和错题列表一次写出去批改结果要落成成绩表才闭环。常见做法是用openpyxl直接写xlsx列结构固定为考号、得分、正确题数、错题列表。错题列表用字符串按题号拼起来方便教师扫一眼就知道问题分布。from openpyxl import Workbook def export_scores(records, excel_path): wb Workbook() ws wb.active ws.title 成绩表 ws.append([考号, 得分, 总题数, 错题]) for rec in records: student_id, score, total, wrong rec wrong_text ,.join(str(w[0] 1) 题 for w in wrong) ws.append([student_id, score, total, wrong_text]) wb.save(excel_path)逻辑说明records是每个考生的结果元组错题列表在这个函数里按行坐标映射成题号字符串。openpyxl的append是按行写入表头先写一行后续逐考生追加。这个脚本跑完就能得到可直接分发成绩表的Excel教师端不再需要二次处理。5. 答题卡检测批改常见问题排查五个高发翻车现场与处理办法跑通demo简单跑真实扫描图才是修罗场。这一章列五条常见翻车记录每一条都是现象、原因、解决办法三段式适合调试时对照着排查。5.1 透视矫正后答案区依旧偏移现象矫正后答题卡看起来是正的但切出来的格子与印刷格错开一两个像素判读结果里整列错位。原因角点检测把背景里的矩形物体错当答题卡边框或者拍照倾斜超15度后单应矩阵外推误差放大。解决先可视化角点和连线确认四个角是否真在答题卡四角再加一条几何校验——四边长度比例应在0.6到1.8之间超范围直接丢弃这一帧倾斜角度超阈值就提示重新拍摄不要硬矫正。5.2 铅笔涂卡颜色浅被漏判现象学生用HB铅笔轻轻涂卡判读结果大面积漏涂得分偏低。原因黑色像素占比阈值固定30%浅涂的灰度值压不到二值化阈值之下二值化后黑色像素数量不够。解决把二值化从固定阈值换成自适应阈值或者把占比阈值从0.3降到0.2。不要单独依赖占比启用4.1里的连通域面积辅助判定浅涂笔划连成片时面积仍然可观。5.3 光照不均把阴影当成涂卡现象拍照时阴影落在答题卡边缘判读出现格子里有阴影就算涂上的情况。原因整图固定阈值二值化阴影处灰度值低被误判成黑色。解决做二值化之前先做CLAHE分块直方图均衡化再把二值化阈值改为自适应阈值。阴影和铅笔涂卡的一个差异是边缘分布更模糊可以把涂卡格边缘一圈设为无效区只统计格子中心区域的黑色像素。5.4 双面扫描时背面文字干扰切分现象扫描件包含背面时答题卡边缘渗出的背面文字在投影图里形成噪声带切分数量变多。原因背面的文字笔画透过纸张在灰度图上形成浅灰带固定阈值二值化把它和印刷体一起变成黑色。解决在切行之前先把版心ROI内黑色像素占比低于整行均值30%的条带过滤掉更彻底的办法是按2.3版心坐标约束切分范围根本不做全图投影。5.5 考号漏涂导致成绩串行现象某考生考号没涂满识别结果出现缺位或错位成绩表里这行数据对不上人。原因缺位没有先被判空逻辑拦住模板匹配硬找一个分数最高的数字填进去。解决识别前先做3.3的判空缺位直接标空识别结果还要通过校验——考号位数必须匹配数字必须在合理区间校验不过的状态在Excel里标成待人工复核不要自动落库。6. 用错题统计脚本反向验证判读阈值不再靠玄学调参最后一层不是批改而是验证批改结果。每次考试跑完我都会跑一遍错题分布统计把所有人的错题按题号汇总计算错误率。这组数字是判读参数的试金石——如果某道题错误率异常高先别急着怪学生很可能是这一行的选项格被光照阴影干扰判读阈值需要单独看。def wrong_statistics(all_wrong, total_questions): # 统计每题错误次数入参是第4章收集的答案比对结果 stat [0] * total_questions for wrong_list in all_wrong: for r, c, ans, stu in wrong_list: stat[r] 1 return stat用法是把所有考生的wrong_list汇总进来stat下标是行号值是错误次数。看到某行错误率明显偏高时回到4.1的is_marked函数把那行所有格子的black像素占比打出来看是集中在30%以下还是普遍在30%附近振荡。前者说明学生确实没涂后者说明阈值设高了往下调几个百分点重跑一遍。如果配合python数据分析与可视化的习惯还能把错题分布画成柱状图一眼看出哪些题是教学薄弱点哪些题是判读参数问题。这一步不参与批改但建议写进批改流水线的末尾因为考试场景里最怕的就是成绩表分数和实际答题情况对不上。我最早调判读阈值全靠玄学改一版跑一遍跑完看结果再改后来养成习惯每次批改完先看错题统计统计异常再去查对应格的像素占比而不是直接动全局参数。这样调参有依据也省掉了大量重复跑图的等待时间。希望帮到你。本文还有配套的精品资源点击获取