简介:这份资源围绕“基于Hough变换的答题卡识别”展开,面向计算机视觉、图像处理与模式识别方向的学习者和开发者,适合作为课程设计、毕业设计或算法练手项目的参考方案。内容涵盖Hough变换检测答题卡选择区域、图像分割与预处理、模式识别分类、坐标映射以及算法优化等关键环节,帮助读者理解从图像输入到答案输出的完整自动化流程。压缩包共14个文件,以13个m脚本文件和1张jpg图片为主,脚本分别承担灰度转换、二值化、平滑、旋转校正、区域分割、Hough处理、角度计算与结果写入等模块,图片用于测试与效果展示,整体约5.19MB,结构紧凑便于按模块阅读。目前已有1647人学习下载,说明该方案在同类项目中具有一定参考价值。读者可借此梳理答题卡识别的实现思路,掌握各处理步骤的衔接方式,并在此基础上进行算法替换与性能优化。
1. 答题卡识别里的 Hough 变换:从一张歪斜的答题卡说起
答题卡识别这件事,听起来像是 OCR 的一个小分支,但真正上手做过的人都知道,它跟通用文字识别完全是两条路。通用 OCR 面对的是自然场景文字,字体、大小、排列都不固定;答题卡不一样,它的结构高度规整——固定位置的选项框、固定的行列间距、固定的填涂方式。这种规整性反而给了我们一个优势:可以用几何方法先把结构找出来,再做局部判别,而不是让模型去硬猜。
Hough 变换就是在这个环节里最常被拿出来用的工具。它的核心作用不是“识别”,而是“定位”——把答题卡图像里那些倾斜的边界线、框线、行列基准线找出来,为后续的透视校正和区域切分提供依据。换句话说,Hough 变换解决的是“答题卡在图像里歪了多少、怎么把它摆正”这个问题。如果这一步没做好,后面无论用什么分类器,准确率都会大打折扣。
这份资源适合两类人:一类是正在做课程设计或毕设、需要一套能跑通的答题卡识别流程的开发者;另一类是想理解 Hough 变换在实际图像处理任务中怎么落地、而不是只停留在理论公式上的工程师。它不涉及深度学习训练,核心是 OpenCV 传统图像处理管线,对算力要求低,适合在普通笔记本上复现。
2. Hough 变换定位答题卡:原理、参数与预处理链路
2.1 为什么答题卡识别要先做边缘和直线检测
答题卡的图像在采集时几乎不可能完美水平。手机拍摄会有手持倾斜,扫描仪进纸会有几度的偏转,甚至同一台设备不同批次的纸张摆放也会有差异。如果不做校正,后续按固定坐标去切选项框,切出来的区域会偏移,填涂判定的阈值就会失效。
常见做法是先用 Canny 边缘检测把答题卡的轮廓和内部框线提取出来,再用 Hough 变换检测其中的直线段。答题卡上最明显的直线有两类:一是答题卡的外边框,二是选项区域的行列分隔线。外边框用来做透视校正,行列线用来做区域切分。
这里有一个容易翻车的地方:很多人直接对原图做 Hough 变换,结果检测出一堆杂乱线段。原因是原图里填涂的黑色方块、印刷文字、阴影都会产生边缘。所以预处理链路必须是“灰度化 → 高斯模糊 → 自适应二值化 → Canny 边缘检测 → Hough 变换”,每一步都有它的作用,不能跳。
import cv2 import numpy as np def preprocess_for_hough(image_path): # 读取图像,保留彩色用于后续可视化 img = cv2.imread(image_path) # 转灰度,减少通道干扰 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊,核大小 5x5,抑制印刷纹理和噪点 blurred = cv2.GaussianBlur(gray, (5, 5), 0) # 自适应二值化,应对光照不均 binary = cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, blockSize=15, C=10 ) # Canny 边缘检测,双阈值根据图像对比度调整 edges = cv2.Canny(binary, 50, 150, apertureSize=3) return img, edges这段代码里几个参数值得展开说。GaussianBlur的核大小选 5×5 是一个平衡点,太小去不掉噪点,太大会把细线也模糊掉。adaptiveThreshold的blockSize设为 15,意味着每个像素参考周围 15×15 区域计算阈值,这个值要略大于答题卡上最粗的框线宽度,否则框线内部会出现空洞。C值是常数偏移,设为 10 可以避免在光照均匀区域产生过多噪点。Canny 的 50/150 是经验值,如果图像偏暗或偏亮,需要按比例调整。
2.2 标准 Hough 变换与概率 Hough 变换的选型
OpenCV 提供了两个 Hough 直线检测接口:HoughLines和HoughLinesP。前者输出的是极坐标下的直线参数(rho, theta),后者输出的是线段的两个端点坐标。在答题卡识别场景里,我一般会优先用HoughLinesP,原因很直接:我们需要的是“某一条具体的框线在哪里”,而不是“图像中存在一条角度为 theta 的直线”。端点坐标可以直接用来计算交点、判断线段长度和位置,省去了从极坐标反推的麻烦。
def detect_lines(edges): # 概率 Hough 变换,输出线段端点 lines = cv2.HoughLinesP( edges, rho=1, # 距离分辨率,1 像素 theta=np.pi / 180, # 角度分辨率,1 度 threshold=80, # 累加器阈值,越高越严格 minLineLength=100, # 最短线段长度,过滤碎线 maxLineGap=10 # 同一直线断口最大间隔 ) return linesthreshold=80意味着一条直线上至少要有 80 个边缘点才会被判定为直线。答题卡框线通常比较长,这个值可以设高一些来过滤噪声。minLineLength=100会丢掉那些短于 100 像素的线段,正好可以过滤掉填涂方块和文字的边缘。maxLineGap=10允许同一条直线中间有 10 像素以内的断裂,这是因为二值化后框线可能不连续。
如果答题卡图像分辨率很高(比如 3000×4000),这些参数要等比例放大。我一般会先把图像缩放到宽度 1000 左右再做检测,这样参数比较稳定,检测完再把坐标映射回原图。
2.3 从直线集合到答题卡姿态:角度聚类与透视校正
HoughLinesP输出的是一堆线段,有水平的、垂直的、还有各种斜的。下一步是从中找出答题卡的主方向。常见做法是计算每条线段的角度,把角度归一到 [0, 180) 区间,然后做聚类或直方图统计。答题卡的主方向通常集中在两个峰值附近,相差约 90 度。
def estimate_skew_angle(lines): angles = [] for line in lines: x1, y1, x2, y2 = line[0] # 计算线段角度,归一到 [0, 180) angle = np.degrees(np.arctan2(y2 - y1, x2 - x1)) % 180 angles.append(angle) angles = np.array(angles) # 用中位数而不是均值,避免异常线段拉偏 median_angle = np.median(angles) # 把接近 90 度的归到 0 度方向 if median_angle > 45: median_angle -= 90 return median_angle这里用中位数而不是均值,是因为 Hough 结果里总会有一些杂线,均值容易被带偏。得到倾斜角后,用cv2.getRotationMatrix2D和cv2.warpAffine做旋转校正。如果倾斜比较严重,或者拍摄时有透视变形,旋转就不够了,需要用四点透视变换。四点透视变换的关键是找到答题卡的四个角点,这可以通过对 Hough 线段做延长求交点来实现,也可以先用轮廓检测找到最大四边形轮廓。
提示:旋转校正后图像边缘会出现黑边,后续切分区域时要按校正后的有效区域重新计算坐标,不能沿用原图坐标。
3. 填涂区域切分与判定:从校正图到选项结果
3.1 基于行列投影的选项框定位
校正后的答题卡基本是水平的,接下来要定位每个选项框。常见做法是二值化后做水平投影和垂直投影。水平投影的波峰对应行分隔线,垂直投影的波峰对应列分隔线。根据波峰位置可以把答题卡切成一个个单元格,每个单元格对应一道题的选项区域。
def find_row_col_positions(binary_img): # 水平投影:每行黑色像素数 row_sum = np.sum(binary_img == 255, axis=1) # 垂直投影:每列黑色像素数 col_sum = np.sum(binary_img == 255, axis=0) # 找波峰,波峰位置即分隔线位置 row_peaks = np.where(row_sum > 0.5 * np.max(row_sum))[0] col_peaks = np.where(col_sum > 0.5 * np.max(col_sum))[0] # 对相邻峰做合并,避免一条线被拆成多个峰 row_lines = merge_peaks(row_peaks, gap=5) col_lines = merge_peaks(col_peaks, gap=5) return row_lines, col_lines0.5 * np.max这个比例系数需要根据实际图像调整。如果答题卡框线比较细,比例要调低;如果填涂区域很大,比例要调高,否则填涂块会被误判为分隔线。merge_peaks的作用是把距离小于 5 像素的峰合并成一个,因为一条粗线在投影上可能产生连续多个峰。
3.2 填涂判定的阈值策略与鲁棒性处理
每个选项框切出来后,判定是否被填涂,最直接的方法是统计框内黑色像素占比。占比超过某个阈值就判为填涂。但这里有几个坑:不同铅笔浓度不同、扫描仪对比度不同、同一张卡上不同区域的光照也可能不同。
我一般会用双阈值策略:高阈值以上确定填涂,低阈值以下确定未填涂,中间区域标记为“可疑”,再结合该题其他选项的情况做二次判断。比如一道题有 A、B、C、D 四个选项,如果只有一个选项超过低阈值,那即使没到高阈值也判为填涂;如果有两个以上超过低阈值,才需要严格按高阈值来。
def judge_fill(roi, low_thresh=0.15, high_thresh=0.35): # roi 是单个选项框的二值图像 black_ratio = np.sum(roi == 255) / roi.size if black_ratio >= high_thresh: return 1, black_ratio # 确定填涂 elif black_ratio <= low_thresh: return 0, black_ratio # 确定未填涂 else: return -1, black_ratio # 可疑,交给后续逻辑low_thresh=0.15和high_thresh=0.35是经验值,实际项目里会用一批标注好的答题卡做统计,画出填涂和未填涂的黑色占比分布,取两个分布之间的谷底作为阈值。如果资源包里没有标注数据,可以先用手动调参的方式跑几张样图,观察可疑区域的比例,再微调。
3.3 多题多选项的结果输出与置信度标记
判定完所有选项后,需要按题号汇总。每道题可能有单选或多选,输出格式一般是一个二维数组,行是题号,列是选项,值为 0/1/-1。对于可疑项,可以在输出里加一个置信度字段,方便人工复核。
def aggregate_results(questions): # questions 是每道题的选项判定结果列表 results = [] for q_idx, options in enumerate(questions): filled = [i for i, v in enumerate(options) if v == 1] uncertain = [i for i, v in enumerate(options) if v == -1] results.append({ "question": q_idx + 1, "answer": filled, "uncertain": uncertain, "confidence": "high" if not uncertain else "low" }) return results这个结构可以直接序列化成 JSON,方便后续导入阅卷系统或做人工复核界面。置信度为 low 的题目会带上可疑选项的索引,复核时只需要看这些题,不用全量检查。
4. 避坑与排查:Hough 答题卡识别里最容易翻车的五个点
4.1 检测不到外边框,只检测到内部框线
现象:Hough 结果里全是短线段,找不到答题卡的四条外边。原因通常是答题卡外边框在图像边缘被裁掉了,或者外边框颜色太浅,二值化后没有形成连续边缘。解决方法是先做轮廓检测,找到最大四边形轮廓作为答题卡区域,再在这个区域内做 Hough。如果外边框确实缺失,可以用内部框线的延长线求交点来估算四个角点。
4.2 倾斜角估计偏差大,校正后仍然歪
现象:校正后的答题卡看起来还是斜的,或者旋转方向反了。原因一般是角度聚类时把垂直线的角度也混进去了,导致中位数偏移。解决方法是先按线段长度加权,只保留最长的若干条线段参与角度估计;或者分别统计接近水平和接近垂直的两组角度,取水平组的中位数作为倾斜角。
4.3 填涂判定阈值在不同批次图像上失效
现象:同一套参数在 A 批次扫描件上准确率很高,换到 B 批次就大量误判。原因是不同批次的扫描亮度、对比度、铅笔浓度有差异。解决方法是在预处理阶段加一步光照归一化,比如用 CLAHE 做自适应直方图均衡,或者用大核高斯模糊估计背景光照再做除法校正。阈值本身也应该做成可配置的,不要硬编码在函数里。
4.4 选项框切分错位,答案整体偏移一位
现象:识别结果整体偏移,第 1 题的结果对应到了第 2 题。原因是行列投影的波峰合并参数不合适,导致分隔线数量对不上。解决方法是把检测到的分隔线数量与答题卡规格做校验,如果数量不符,回退到基于固定间距的切分方式,或者提示人工确认。
4.5 多选和涂改场景下的误判
现象:考生涂改后留下浅痕,或者多选题只涂了一部分,判定结果不稳定。原因是黑色占比处于低阈值和高阈值之间的可疑区。解决方法是在可疑区引入形态学操作,先做开运算去掉浅痕,再做闭运算连接断裂的填涂块,然后重新计算黑色占比。对于多选题,可以结合题目类型先验——如果题目规定是单选,那即使两个选项都超过低阈值,也只取黑色占比最高的那个。
5. 进阶技巧:用 Hough 结果做答题卡模板自适应
前面讲的流程有一个隐含假设:答题卡的版式是固定的,行列位置可以通过投影一次性确定。但实际项目里经常会遇到不同科目、不同题量的答题卡混在一起,甚至同一张卡上有多个区域。这时候可以用 Hough 检测到的框线结构来做模板自适应。
具体做法是:把 Hough 检测到的所有线段按角度分成水平组和垂直组,分别按位置排序,得到一个“线位置序列”。这个序列的间隔模式就是答题卡的版式指纹。把已知版式的指纹存成模板,新图像检测到线位置序列后,与模板做匹配,匹配度最高的模板就是当前答题卡的版式。匹配时用间隔比例的余弦相似度,而不是绝对位置,这样对缩放和轻微平移不敏感。
def match_template(line_positions, templates): # line_positions: 检测到的线位置列表 # templates: 已知版式的线位置列表字典 intervals = np.diff(sorted(line_positions)) intervals = intervals / np.sum(intervals) # 归一化为比例 best_match = None best_score = -1 for name, tmpl in templates.items(): tmpl_intervals = np.diff(sorted(tmpl)) tmpl_intervals = tmpl_intervals / np.sum(tmpl_intervals) # 长度对齐后计算余弦相似度 min_len = min(len(intervals), len(tmpl_intervals)) a = intervals[:min_len] b = tmpl_intervals[:min_len] score = np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) if score > best_score: best_score = score best_match = name return best_match, best_score这个方法的边界在于:如果答题卡版式差异很小,比如只是题量不同,间隔比例的区分度可能不够。这时候可以加入绝对尺寸信息,或者结合 OCR 识别出的题号做二次确认。另一个注意点是,Hough 检测的线位置会有几个像素的抖动,计算间隔前要先做一次聚类合并,把距离小于 5 像素的线合并成一条。
验证模板匹配是否可靠,我一般会留一批不参与模板构建的答题卡做测试,统计匹配准确率。如果准确率低于 95%,说明模板区分度不够,需要增加模板数量或者引入更多特征。从那以后我每次做版式自适应,都会先跑一遍留出验证集,确认匹配环节不会成为新的误差来源。希望帮到你。
本文还有配套的精品资源,点击获取