☰
MATLAB+Hough变换实现答题卡识别:从直线检测到透视矫正
2026/10/12 9:38:54 网站建设 项目流程

1. 项目概述与整体设计思路

答题卡识别这个题目,在图像处理领域算是一个"麻雀虽小、五脏俱全"的综合练习。我第一次接到类似需求的时候,是在帮某学校处理一份纸质问卷的批量统计,几百张A4纸答题卡,人工录入大概需要一下午,而且容易出错。用MATLAB配合Hough变换做了一套识别程序之后,单张处理时间压缩到0.5秒左右,几百张卡几分钟跑完,准确率稳定在99%以上(前提是填涂别太潦草)。

这个项目的核心链条其实并不复杂:图像采集 -> 预处理 -> Hough直线检测 -> 透视矫正 -> 答题区域定位 -> 填涂判定 -> 结果输出。其中最关键、也最容易把人卡住的两步,一是Hough变换如何稳定检测出答题卡的四条边界,二是在角度倾斜、光照不均的现场照片里如何把填涂区域准确地"抠"出来。这篇博文主要围绕这两个难点展开,同时把整套流程的MATLAB实现思路讲透,适合正在做图像处理课程设计、或者刚接触计算机视觉想找一个小而全的练手项目的朋友参考。

为什么这个选题值得做?因为答题卡识别几乎覆盖了图像处理入门的全部核心模块:灰度化、二值化、边缘检测、直线检测、透视变换、形态学处理、区域统计。你把这套流程跑通一遍,再去搞别的视觉项目(比如表格识别、票据扫描、甚至简单的OCR对齐)都会顺手很多。而且MATLAB的Image Processing Toolbox把这些算法封装得相当友好,用几行代码就能调用成熟实现,非常适合先跑通整体逻辑、再深挖底层原理的学习路径。

我的建议是:先完整跑通下面这套流程,然后试着把每一步的算法替换成自己的实现(比如自己写一个二值化、自己写一个Hough累加器),这样既能理解工程应用,又能加深算法原理的理解。这套思考路径,比单纯跑一个现成Demo要有价值得多。

2. 方案选型:为什么是Hough变换而不是其他方法

2.1 答题卡检测的本质是"找四边形"

答题卡识别第一步要做的事情,是把答题卡区域从背景里分离出来。无论是扫描件还是手机拍摄的照片,答题卡在图像里通常都是一个矩形,但这个矩形会存在以下问题:

  • 拍照时手机没有完全正对答题卡,产生透视畸变,四条边不是理想水平竖直的。
  • 背景里有桌面纹理、其他纸张、阴影等干扰,直接阈值分割很难把答题卡单独抠出来。
  • 答题卡边缘的直线特征非常明显,但在部分模糊、光照不均的情况下,边缘检测的结果可能断裂。

所以第一步匹配到这个场景下最合适的工具:直线检测。而直线检测的经典算法,就是Hough变换。

2.2 Hough变换和其他候选方案的对比

在选型的时候,我实际考虑过的方案不止Hough变换一种:

**基于边缘追踪的轮廓查找(如findContours)**是另一种常见思路。它的优势在于可以直接拿到四边形轮廓的四条边,甚至不需要额外拟合直线。但问题在于,当背景复杂、答题卡边缘和背景粘连时,轮廓查找的结果往往是一堆乱七八糟的小轮廓,你很难判断哪一条是真正的答题卡边界。而Hough变换天然对断裂直线、噪声点有很好的鲁棒性,因为它是基于"投票"的机制——即便一条边在中途断开,只要大部分像素点落在同一条直线上,累加器里依然会形成一个明显的峰值。

基于模板匹配的方案在这个场景里基本不可取。答题卡的版面结构千变万化,题号排列、选项数量、填涂区大小都不一样,不可能做一个统一模板。而且模板匹配对旋转和缩放的适应性很差,答题卡只要倾斜超过几度,匹配分数就崩塌了。

基于深度学习的方案(比如目标检测模型直接框出答题卡)在鲁棒性上其实是更强的,尤其是面对复杂背景和极端光照时,泛化能力远超传统算法。但它的缺点也很明显:需要数据集、需要标注、需要训练、需要GPU,而且部署起来比MATLAB脚本麻烦得多。一个几十张样本、只用来跑通流程的项目,完全没有必要引入深度学习。

所以最终选择Hough变换,核心理由是:在轻量级、单张图片可解释性强的场景下,Hough变换用最少的代码量和最强的几何先验,解决了"从杂乱边缘里找直线"这个核心问题。

2.3 算法理解的底层直觉

说到Hough变换的原理,很多初学者一看公式就头大。其实它的核心直觉非常朴素:图像空间里的一条直线,对应到参数空间里就是一个点;反过来,图像空间里的一个点,对应到参数空间里就是一条正弦曲线。那么多个图像点如果共线,它们在参数空间里的正弦曲线就会相交于同一个位置,这个位置的累加值就会特别高——投票出最大值,直线就找到了。

实际用MATLAB实现时,hough函数、houghpeaks和houghlines这一套组合拳,就是在帮你完成"投票 -> 找峰值 -> 反解直线"这三步。理解这个底层逻辑最大的好处是:当检测结果不理想的时候,你知道该去调什么参数。比如累加器分辨率、峰值邻域大小、阈值高低,这些参数直接影响检测到的直线数量,盲目乱调是调不出好结果的。

3. 实操过程与核心环节实现

3.1 第一步:图像预处理

预处理是整个识别流程的地基。地基建不好,后面Hough变换检出来的直线全是歪瓜裂枣。我在这里的实际流程如下:

% 读入图像 img = imread('answer_sheet.jpg'); if size(img, 3) == 3 gray = rgb2gray(img); else gray = img; end

对于彩色图像,先转灰度是必须的。这一步没什么悬念。然后是去噪,我习惯用一个中值滤波,而不是高斯滤波。原因是中值滤波在去除椒盐噪声(比如扫描产生的散布黑点、白色反光点)的同时,能更好地保留边缘的锐利程度。高斯滤波虽然平滑效果更均匀,但会让边缘变粗,导致后面Canny检测到"双边缘",反而干扰Hough变换。

% 中值滤波去噪 gray = medfilt2(gray, [3 3]);

接下来是二值化。这里有个容易踩的坑:不要直接用全局阈值im2bw(gray, level)。答题卡在扫描或者拍照时,经常出现半边亮半边暗的情况(尤其是有阴影覆盖的时候),全局阈值会把亮部的淡铅笔痕迹吃掉,或者把暗部的背景误判成黑色。我用的方案是graythresh(Otsu方法)计算自适应阈值,这样至少能兼顾整体的明暗分布。如果光照不均特别严重,可以考虑分块阈值或者adaptthresh,但在多数标准扫描场景下Otsu已经够用。

% Otsu自适应二值化 level = graythresh(gray); bw = imbinarize(gray, level);

但注意,这里我实际更推荐的EFA其实是先做边缘检测再做Hough变换,而不是直接用二值图。二值图里答题卡内部有大量文字和填涂块,这些内容也会形成边缘,直接灌给Hough变换会导致检测出的直线数量爆炸,干扰极大。所以正确姿势是:先用Canny提取边缘,再对边缘图做Hough变换。

% Canny边缘检测 edges = edge(bw, 'canny');

Canny的阈值参数需要根据图像质量微调。默认的edge(gray,'canny')在大多数情况下可用,但我实测下来答题卡这种高对比度场景,稍微调高阈值能滤除更多细碎纹理:

% 手动指定Canny阈值 edges = edge(gray, 'canny', [0.1 0.25]);

[0.1 0.25]这个区间通常能保留答题卡边框这种强边缘,同时去掉纸张纹理和轻微阴影造成的弱边缘。当然这个值不是固定的,你得根据自己图像的对比度去微调,经验法则是:让答题卡的四条边框清晰显示,而背景里的干扰边缘尽量消失。

3.2 第二步:Hough变换检测答题卡边界

拿到边缘图之后,进入整个项目最核心的环节:用Hough变换检测直线。

% Hough变换 [H, theta, rho] = hough(edges); % 寻找峰值 peaks = houghpeaks(H, 50, 'Threshold', ceil(0.3 * max(H(:)))); % 提取直线 lines = houghlines(edges, theta, rho, peaks, 'FillGap', 30, 'MinLength', 80);

这里三个参数值得专门拎出来讲。

houghpeaks里的Threshold:这个阈值决定了有多少候选峰值会被认为是直线。设得太低,会检测出一堆由文字笔画产生的短直线;设得太高,真正的答题卡边框可能被漏掉。我一般在0.2到0.4倍max(H(:))之间取值,具体看边缘图质量。

FillGap:这个参数允许两条断开的线段在端点距离小于该值的情况下被连接成一条线。因为扫描时答题卡边框偶尔会有细微断裂,FillGap在30像素左右是个比较稳的折中。

MinLength:设置直线的最短长度。答题卡边框在图像中通常占据较长的距离,但具体的像素长度取决于拍摄分辨率。按常见两千万像素的手机拍摄结果,边框在图上可能有1000像素以上;如果是低分辨率扫描件,可能也有300到500像素。所以我一般设置MinLength为图中长边的1/10左右,在这套场景里取80比较合适。

直线检测出来之后,接下来是筛选和外边界估计。Hough变换会检测出很多直线,我们需要的是最外围的四条边。我的做法是:

  1. 把所有检测到的直线按角度分成两组:接近水平的(|角度|小于45度)和接近垂直的(|角度|大于45度)。
  2. 在水平组里,根据直线中心的纵坐标,选择最上方和最下方的两条。
  3. 在垂直组里,根据直线中心的横坐标,选择最左边和最右边的两条。
% 获取图像尺寸 [imgH, imgW] = size(gray); % 初始化四个边界 topLine = []; bottomLine = []; leftLine = []; rightLine = []; for k = 1:length(lines) p1 = lines(k).point1; p2 = lines(k).point2; ang = atan2(p2(2) - p1(2), p2(1) - p1(1)) * 180 / pi; if abs(ang) < 45 % 水平方向:比较垂直位置 centerY = (p1(2) + p2(2)) / 2; if isempty(topLine) || centerY < topLine.centerY if ~isempty(topLine) && centerY > imgH * 0.5 % 如果上边界在图像下半部分,说明是底部干扰,跳过 else topLine = struct('points', [p1; p2], 'centerY', centerY, 'angle', ang); end end if isempty(bottomLine) || centerY > bottomLine.centerY if ~isempty(bottomLine) && centerY < imgH * 0.5 % 如果下边界在图像上半部分,说明是顶部干扰,跳过 else bottomLine = struct('points', [p1; p2], 'centerY', centerY, 'angle', ang); end end else % 垂直方向:比较水平位置 centerX = (p1(1) + p2(1)) / 2; if isempty(leftLine) || centerX < leftLine.centerX if ~isempty(leftLine) && centerX > imgW * 0.5 % 类似地跳过干扰 else leftLine = struct('points', [p1; p2], 'centerX', centerX, 'angle', ang); end end if isempty(rightLine) || centerX > rightLine.centerX if ~isempty(rightLine) && centerX < imgW * 0.5 % 类似地跳过干扰 else rightLine = struct('points', [p1; p2], 'centerX', centerX, 'angle', ang); end end end end

这个"跳过干扰"的逻辑是我自己踩坑后加上的。因为答题卡内部的行线(题号间的横线、选项分割线)和打印的边框平行,很容易干扰"最外边"的判断。通过判断候选边界的中心位置是否落在图像外侧区域,可以强行排除那些跑到画面中间去的内部线条。这个启发式规则在标准版式下非常有效,省去了更复杂的聚类分析。

3.3 第三步:透视矫正与答题区域定位

拿到四条边界之后,下一步就是计算四个交点,形成四边形。两条直线求交点是个基础几何问题,MATLAB可以直接用polyxpoly函数,或者手算。我喜欢手算,因为不依赖额外工具箱:

% 计算直线交点函数 function point = lineIntersection(l1_p1, l1_p2, l2_p1, l2_p2) % 使用行列式求解 x1 = l1_p1(1); y1 = l1_p1(2); x2 = l1_p2(1); y2 = l1_p2(2); x3 = l2_p1(1); y3 = l2_p1(2); x4 = l2_p2(1); y4 = l2_p2(2); D = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); if abs(D) < 1e-6 point = [NaN NaN]; return; end point = [((x1*y2 - y1*x2)*(x3 - x4) - (x1 - x2)*(x3*y4 - y3*x4)) / D, ... ((x1*y2 - y1*x2)*(y3 - y4) - (y1 - y2)*(x3*y4 - y3*x4)) / D]; end

四个交点算出来之后,顺序可能比较乱(不一定按左上、右上、左下、右下的顺序排列),我一般对四个点按纵坐标排序分成上下两组,每组再按横坐标排序,得到规则的四角点。

然后就是透视矫正。透视矫正的原理是:原始图像里的四边形区域,通过一个单应性矩阵映射到一个标准长方形上。MATLAB里做这件事最直接的工具是fitgeotrans加imwarp:

% 四个原始角点 srcPoints = [topLeft; topRight; bottomRight; bottomLeft]; % 目标长方形尺寸(以标准答题卡比例计算) dstWidth = 1200; dstHeight = round(dstWidth * 0.7); % 标准答题卡一般3:2或7:5 % 目标角点 dstPoints = [0 0; dstWidth 0; dstWidth dstHeight; 0 dstHeight]; % 计算单应性矩阵 tform = fitgeotrans(srcPoints, dstPoints, 'projective'); % 应用变换 correctedImg = imwarp(gray, tform, 'OutputView', imref2d([dstHeight dstWidth]));

这里为什么目标宽度取1200?因为分辨率太低,后续填涂判定时每个填涂区的像素数会太少,比值计算不稳定;分辨率太高,处理速度又会变慢。1200像素宽度对这个场景来说,每个填涂区大约能分配到80到100像素的直径,足够做可靠判定。

矫正之后,答题卡姿态就是标准的长方形了。接下来要做的是定位每一个填涂区。这里我选择的策略是:规则网格等距分割。前提是答题卡的填涂区呈规则矩阵排列,且题目编号顺序连续、无跳题。如果是非常规排版,就得用投影法(在水平和垂直方向做像素累加,找波谷分割)来确定行列位置。

对于标准排版,行列定位的伪代码如下:

% 设定参数:题目数和选项数 numQuestions = 25; % 假设25道题 numOptions = 4; % A/B/C/D % 在矫正图上按先验行间距与列间距分割 % 先通过水平投影找每行的分界线 horizProj = sum(bwCorrected, 2); % 每行黑色像素数 % 波谷处即为行间空隙

这个方法需要先预设大致的行列分布。更通用的做法是利用填涂区的几何特征:填涂区通常是一个固定大小的圆或矩形,可以通过连通域分析(bwlabel+regionprops)把所有填涂区找出来,然后对每个区域中心点做排序,自动生成行列索引。这种方法对不规则的排版适应性更好,但前提是bwCorrected里的填涂区必须填充充分(不能是空心圆)。

我的建议是分两步走:先用先验网格切分,如果不行再改用连通域。

3.4 第四步:填涂判定

填涂判定其实是个分类问题:对每个填涂区,判断它是"已填涂"还是"未填涂"。最直接的特征是填涂区内黑色像素的比例。

% 对每个区域计算填充率 for q = 1:numQuestions for opt = 1:numOptions % 假设每个填涂区是rc×cc矩形 rect = [colStart(q, opt), rowStart(q, opt), rHeight, cWidth]; areaBw = bwCorrected(rowStart:rowStart+rHeight-1, colStart:colStart+cWidth-1); fillRatio(q, opt) = sum(areaBw(:)) / (rHeight * cWidth); end end

但直接使用全局固定阈值存在一个陷阱:如果填涂区本身是空心圆形(常见于部分答题卡,要求考生把答案涂黑圆圈),未填涂时依然是白色,所以填充率接近0,容易判定;但如果填涂区是一个"□"形状,未填涂时边框本身就是黑色边框,填充率被抬高,就会误判。

解决方法是引入孔洞扣除:先在填涂区内部取一个稍微缩小的内部区域,只统计内部区域的黑色像素比例。或者直接用连通域的性质:已填涂的区域通常是一个面积较大、实心的连通域;未填涂的区域要么没有连通域,要么连通域的面积非常小。

% 对每个区域的内部子区域求填充率 innerRatio = 0.2; % 取区域中心20%作为内部 offsetRow = round(rHeight * (1 - innerRatio) / 2); offsetCol = round(cWidth * (1 - innerRatio) / 2); innerRect = [rowStart + offsetRow, colStart + offsetCol, ... round(rHeight * innerRatio), round(cWidth * innerRatio)]; innerBw = bwCorrected(innerRect(1):innerRect(1)+innerRect(3)-1, ... innerRect(2):innerRect(2)+innerRect(4)-1); fillRatio = sum(innerBw(:)) / numel(innerBw);

这个"内部区域判定"的方法非常有效,能显著降低误判率。至于阈值,我实测下来:填充率大于0.4视为已填涂,低于0.2视为未填涂,0.2到0.4之间属于模糊区域,需要特殊处理(可以是标记为"不清晰待人工复核")。如果考生的铅笔笔迹很轻(灰度值不是很黑),那么bwCorrected二值化之后填充率会偏低,这时候需要配合原始灰度信息综合判断。

3.5 完整输出示例

最后一步,把识别结果输出到Excel或CSV文件。我推荐直接写CSV,方便后续用任何工具处理:

% 输出结果 results = cell(numQuestions, 2); for q = 1:numQuestions [maxVal, maxIdx] = max(fillRatio(q, :)); if maxVal > 0.4 results{q, 1} = q; results{q, 2} = char('A' + maxIdx - 1); else results{q, 1} = q; results{q, 2} = 'X'; % 未填涂 end end T = cell2table(results, 'VariableNames', {'题号', '答案'}); writetable(T, 'results.csv');

到这里,整套识别流程就完整走通了。

4. 常见问题与排查技巧实录

在实际做这个项目的过程中,有几个问题几乎每一次都会遇到。我把它们整理成速查表,方便大家直接对照排查。

问题现象可能原因解决方案
Hough检测到大量杂乱直线边缘图过密调高Canny阈值;对边缘图先做thicken或膨胀消除断线
检测不到答题卡边框边框断裂太严重调大FillGap;调低MinLength;或先对二值图做闭运算补全
矫正后图像扭曲交点顺序错乱检查四个角点的排序逻辑,必要时手动指定顺序
填涂率整体偏低铅笔笔迹过浅二值化前先做灰度拉伸、或者改用局部自适应阈值
某一行或多行整体漏检网格行分割时阈值设错用连通域定位替代固定网格,鲁棒性更强
答题卡边缘与背景粘连扫描时有阴影加强背景分割,或者在预处理时做背景减除
空选项和填涂项都检测为高填充率填涂区本身有黑色边框使用内部区域判定法,只统计中心区域
图片分辨率太高导致处理慢单张超过4000万像素先按比例缩小到短边1600像素,矫正后再放大回去

4.1 光照不均的实战处理经验

光照不均是最常见的问题之一。尤其是用手机拍摄答卷时,窗口边的强光很容易让纸面一半过曝。

我踩坑之后的解决方案是:不要只做一个全局Otsu,而是在二值化之前先做一次顶帽变换(Top-hat)或同态滤波来缓解光照不均。用顶帽变换的话,MATLAB一行代码:

% 顶帽变换去除光照不均 se = strel('disk', 50); corrected = imtophat(gray, se); bw = imbinarize(corrected, graythresh(corrected));

顶帽变换的核心原理是:从原图中减去形态学开运算后的背景估计,保留比背景暗的细节。对于答题卡这种背景为白色、前景为黑色的情况,顶帽变换能有效压制光照渐晕,但要注意结构元素尺寸要大于填涂区的尺寸,否则填涂区会被当成背景减掉。

4.2 透视矫正后仍有轻微扭曲

如果fitgeotrans选用的角点不够准确(比如某一边检测的直线稍微偏内),矫正结果就会产生梯形变形。一个提升精度的办法是:在透视矫正之后,再做一次二次Hough检测。因为矫正后的答题卡四条边应该严格水平竖直,此时再检测直线、再矫正一次,能把残差压缩到极小。这种方式对于高精度场景(比如印刷题目卡片的坐标定位)非常必要。

4.3 填涂判定时的个性化学号区域处理

很多答题卡除了题目选项之外,还有一个学号/考号填涂区。学号通常由多列数字组成,每一列是一个0到9的竖条,考生在对应数字处填涂。这个区域的处理和题目选项略有不同:因为每一列是一个10选1的组,判定时是在一列里找到填充率最大的那个数字,而不是独立设置阈值。如果硬套题目选项的逻辑,会出现"这一列没有一个超过0.4"的情况,最终结果为空。所以学号区的判定逻辑是:

% 学号区处理:每列选最大 for col = 1:numDigits [maxVal, maxIdx] = max(digitFillRatio(:, col)); studentNumber(col) = maxIdx - 1; % 0-9 end

这种"组内最大投票"的思路其实可以沿用到很多类似的批量填涂场景,比如意见调查表里的单选矩阵。

4.4 识别结果校正策略

不管算法怎么优化,总会遇到个别涂得歪歪扭扭、或者擦得不干净的卡。我的做法是:不追求100%全自动,而是在输出结果时增加一个"置信度"字段。当某道题的最大填充率和次大填充率差距小于阈值时,把这题标记为"低置信度",导出Excel时用黄色标注。人工只需要扫一眼黄色格子的题目,比全量复核效率高得多。这个思路和工业视觉检测里的"缺陷复判"是同一个逻辑,很值得借鉴。

5. 踩坑记录与个人体会

最后聊几个印象比较深的坑,说点实际经历。

第一个坑是关于"电子版答题卡"和"照片答题卡"的差异。如果输入的图像本身就是由Word或PDF导出后的电子打印版,那几乎没有光照和透视问题,整个流程可以极度简化:灰度化、直接二值化、连通域提取填涂区,结束了。但现场照片的复杂度会陡然上升,尤其是手机拍摄时,答题卡在画面里可能只占不到一半,背景的杂乱程度会直接影响前期直线检测。我的建议是:无论输入源头是什么,统一按照"现场照片"的最高难度来设计流程,这样程序才有足够的兼容性。实际做项目时,我会在开头加一个简单的判断:如果图像尺寸巨大且背景复杂度高,就启用完整的Hough+透视矫正流程;如果已经是规整的扫描图,走一个轻量级分支。这样能压榨出最优的性价比。

第二个坑是关于Hough变换角度的符号约定。不同版本的MATLAB,hough函数返回的theta范围是-90°到90°,但houghlines返回的角度是用point1和point2两点连线的斜率表示的,直接用atan2d(diffY, diffX)算出的角度范围是-180°到180°。如果你不统一两者的角度坐标约定,分组筛选水平线和垂直线的时候很容易出现莫名其妙的错分。我后来统一用两点坐标的atan2d结果重新计算每条线自己的角度,不再依赖line.theta字段,就再没出过这个错。

第三个坑是关于内存的。如果输入图是几千万像素的高分辨率照片,hough函数生成的累加器矩阵会非常巨大,MATLAB在32位环境下甚至可能直接内存溢出。我后来在预处理阶段会先把图像按短边缩放到1600像素,这个分辨率足够检测边框了。矫正之后再把目标输出设置在1200到1500像素之间,兼顾精度和速度。

第四个坑是二值化的"极性"问题。我在调试时遇到过:二值化之后填涂区是黑色,背景是白色;但如果对待扫描件的判断逻辑是"检测白色区域",那模型的极性就反过来。在MATLAB里,imbinarize默认将灰度大于阈值的置为白色(1)、小于阈值的置为黑色(0)。填涂区是黑色像素,所以后续所有统计都是基于sum(bw == 0)。但是如果用im2double之后做逻辑取反,很容易把极性搞反,导致最终识别结果全是空的。建议所有代码统一约定:填涂区=黑色=0值,所有统计都基于0值,这样逻辑最清晰。

做这个项目的最终体会是:Hough变换在答题卡识别中的价值,不在于它多么高大上,而在于它把"在凌乱边缘里找整体结构"这个看似需要"智能"的问题,简化成了一个可解释、可调参、可复现的投票过程。这套思路在整个计算机视觉领域里其实相当通用——只要你要处理的图像里存在直线结构(车道线、表格线、文档边界、棋盘格角点),Hough家族的方法就依然有它的用武之地。哪怕现在深度学习已经把很多端到端任务都"吃"掉了,但理解Hough变换,能够让你在遇到那些"没有训练数据,只有一张图"的老实场景时,依然有从容应对的底气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询