简介:基于MATLAB Hough变换的答题卡识别系统毕业项目源码,配有GUI图形界面,面向计算机、通信、自动化等专业的学生及从业者,可作为毕设、课程设计或期末大作业的参考实现,解决客观题填涂区域定位、倾斜校正与自动判卷等典型问题。压缩包共19个文件,以15个.m源文件为主,涵盖图像灰度化、二值化、形态学处理、Hough直线检测、图像旋转、区域分割与答案对比等完整流程;另含.fig界面文件、Excel标准答案表及答题卡示例图片,整体仅3.3MB,模块划分清晰,便于逐段研读与二次改造。目前已有329人学习下载。代码经过调试可直接运行,并附有GUI交互界面可直观观察识别效果,适合MATLAB图像处理入门者理解算法原理,也适合有基础者在此基础上扩展为其他表单识别项目。
1. 为什么这套MATLAB hough变换答题卡识别一定要先解决“卡歪了”的问题
手写答题卡识别在毕业设计里看着是图像处理三件套的活:先二值化,再找连通域,最后统计涂黑面积。可一旦换成手机拍出来的图,第一步就翻车——卡纸在画面里不是平行四边形就是梯形,直接按坐标切割选项区,边缘题号会整体错位。到这里你才会发现,阈值和连通域解决的是“涂了没有”,而它假设的基础是“每个选项的位置已知”,这个基础恰恰要由hough变换来建立。Hough在整条识别链路里不负责判断答案,它负责两件事:用直线投票找出答题卡的四条边并算出倾斜角,再用圆投票把每一行选项的圆心坐标列出来。下文给的这套MATLAB源码思路,就是把这两步做成可调参数的模块,再包一层GUI界面,适合想把“识别答题卡”当课程设计或毕业作品、且后续还要往文档扫描方向扩展的人。有5年以上图像处理经验的读者可以直接跳到第2章的参数边界和第5章的稳定性验证。
2. MATLAB里用hough变换检测答题卡边界并进行倾斜校正
2.1 为什么这里一定要用Hough而不是直接找外接矩形
答题卡版面通常有一圈黑色边框线,或者四个角有印刷定位块,四边是图像里最长、最稳定的直线特征。倾斜校正的常见做法是先找到这四条边,再算旋转角度。直接调regionprops拿Orientation或者用轮廓求最小外接矩形,在边缘完整时也能出结果,但一遇到扫描亮度不均、边框某个角被手指挡住,边缘断裂后外接矩形的角度会漂移几度,识别率立刻掉下来。
Hough变换不一样。它把每个边缘点投到参数空间,一条直线上的所有点都在同一个(ρ, θ)网格里累加,边缘缺一段,剩下的投票仍然能把峰值顶起来。这种“少几张票也能赢”的特性,让Hough在答题卡这种受拍摄角度、遮挡、手写痕迹干扰的场景里明显比外接矩形稳。代价是参数多了一个量化步长和投票阈值,需要经验值。
2.2 直接用hough函数写一个最小校正模块
MATLAB自带的hough、houghpeaks、houghlines三个函数把投票、峰值检测、线段还原都封装好了,自己写的核心逻辑其实只剩“从检测到的线段里选出边框”这一步。我一般会把它包成一个独立函数,输入灰度图,输出旋转校正后的图和估计的倾斜角。
function [imgFixed, estTheta] = houghCorrectSlant(imgGray, debugFig) % 参数检查:debugFig 为 true 时把检测到的直线画出来,方便调阈值 if nargin < 2, debugFig = false; end % 1. Canny 边缘检测:低阈值 0.06 高阈值 0.18,对轻微光照不均不敏感 bw = edge(imgGray, 'canny', [0.06 0.18]); % 2. Hough 变换:角度分辨率 0.5 度,范围 -89~89 [H, T, R] = hough(bw, 'Theta', -89:0.5:89); % 3. 取投票数最高的 30 个峰值,阈值设为最大投票数的 35% peaks = houghpeaks(H, 30, 'Threshold', 0.35 * max(H(:))); % 4. 还原线段:同一线段上断点间隔 50 像素以内补全,短于 120 像素的线段丢弃 lines = houghlines(bw, T, R, peaks, 'FillGap', 50, 'MinLength', 120); if isempty(lines) imgFixed = imgGray; estTheta = 0; return; end % 5. 按线段长度排序,取最长的若干条作为边框候选 lens = arrayfun(@(s) norm(s.point2 - s.point1), lines); [~, idx] = sort(lens, 'descend'); top4 = lines(idx(1:min(4, numel(idx)))); % 6. 分开统计接近水平(theta 接近 -90 或 90)和接近垂直(theta 接近 0)的线段 angleList = arrayfun(@(s) s.theta, top4); horiz = angleList(abs(abs(angleList) - 90) < 15); vert = angleList(abs(angleList) < 15); if ~isempty(horiz) theta = mean(horiz); elseif ~isempty(vert) theta = mean(vert) + 90; else theta = 0; end % 7. 校正:theta 定义的是水平边相对原图的角度,反向旋转即拉正 imgFixed = imrotate(imgGray, -theta, 'bilinear', 'crop'); estTheta = theta; % 调试:把候选直线叠加显示在原始灰度图上 if debugFig figure; imshow(imgGray); hold on; for k = 1:numel(top4) xy = [top4(k).point1; top4(k).point2]; plot(xy(:,1), xy(:,2), 'LineWidth', 2, 'Color', 'red'); end title(sprintf('Detected edges, theta = %.2f deg', theta)); end end这段代码里最需要解释的是第6步。houghlines输出的theta是直线的法线角度:水平线输出±90,垂直线输出0。很多初学者直接拿最长线的theta去旋转,结果拍横版卡时被一道横排涂痕干扰,角度偏了1度多。把候选线段分成水平、垂直两组,分别取均值再合并,是消除这类干扰最便宜的方法。imrotate的crop参数保证输出尺寸不变,但会丢掉四角像素——答题卡边框通常离边很近,四个角丢一点不影响后续选项定位。
2.3 更稳的作法:四边分别投票,避免被长横线带偏
如果一个版面里除了边框还有大段的段落线或答题区域的分隔线,按“全局最长线段”挑边框就不保险了。我一般会改成对整个参数空间做两次独立投票:第一次在-90 ± 15度范围内找水平方向的最长直线,第二次在0 ± 15度范围内找垂直方向的最长直线,然后用这两个方向角度的组合去算旋转量。这样即使答题区里有一条比边框还长的横线,它也只会挤掉水平方向的候选,而垂直方向的边框仍然能给出正确的倾斜信息。
% 水平方向单独投票 horizL = houghlines(bw, T, R, peaks, ... 'FillGap', 50, 'MinLength', round(size(bw,2)*0.4)); % 垂直方向单独投票 vertMask = abs(T) < 15; [Hv, Tv, Rv] = hough(bw, 'Theta', T(vertMask)); peaksV = houghpeaks(Hv, 10, 'Threshold', 0.3 * max(Hv(:))); vertL = houghlines(bw, Tv, Rv, peaksV, ... 'FillGap', 50, 'MinLength', round(size(bw,1)*0.4));注意这里MinLength不再写固定值,而是取图像宽高的40%,这样换不同分辨率的相机时不用重新调参。水平投票和垂直投票的FillGap保持一致,避免横竖边缘断裂程度不同导致某一边完全检测不到线。
3. hough圆变换定位选项网格,再用区域像素统计判断涂卡结果
3.1 为什么选项定位适合用imfindcircles而不是连通域
倾斜校正做完,下一步是把每一题的AB…E选项位置标出来。标准答题卡是空心圆或方框,涂卡后圆圈内部变黑。定位这些圆有两种主流做法:一是二值化后找连通域,按面积筛选候选圆;二是用Hough圆变换直接投票找圆。第一种对笔迹溢出、橡皮擦残留非常敏感,一个涂得不规矩的圆可能分裂成三个连通域,还要靠形状比例二次过滤。第二种把“圆”这个几何特征显式建模,即使圆的边缘只有三分之一可见,投票累积仍然能定位到正确圆心。
MATLAB里imfindcircles是Hough圆变换的工程化实现,参数比自写投票函数直观,但要注意它的Sensitivity很敏感,调高一点就会把题号里的数字圈也当成选项圆。
3.2 圆检测与圆心网格化排序
function [gridX, gridY, radii] = locateOptionsWithHough(imGray) % imfindcircles 检测暗色圆:ObjectPolarity='dark' 表示找比背景暗的圆 % RadiusRange 要按图像分辨率估,建议用图像宽度的 0.5%~1.2% minR = round(size(imGray,2) * 0.008); maxR = round(size(imGray,2) * 0.02); [centers, radii] = imfindcircles(imGray, [minR maxR], ... 'ObjectPolarity', 'dark', ... 'Sensitivity', 0.88, ... 'EdgeThreshold', 0.15); % 按行聚类:圆心 Y 坐标准确体现了行号 ys = sort(centers(:,2)); gap = diff(ys); % 相邻圆心间距的中位数是圆直径,行间距明显大于它 rowBreak = find(gap > 2.5 * median(gap)); rowId = ones(size(centers,1), 1); for i = 1:numel(rowBreak) rowId(centers(:,2) > ys(rowBreak(i)) + 1e-6) = i + 1; end % 每行内按 X 排序,得到 (题号, 选项列) 到坐标的映射 nRows = max(rowId); nCols = max(accumarray(rowId, 1, [], @max)); gridX = nan(nRows, nCols); gridY = nan(nRows, nCols); for r = 1:nRows sel = centers(rowId == r, :); [~, order] = sort(sel(:,1)); sel = sel(order, :); nColsReal = size(sel,1); gridX(r, 1:nColsReal) = sel(:,1)'; gridY(r, 1:nColsReal) = sel(:,2)'; end end这段代码的聚类方式很朴素,利用的是答题卡版式的硬约束:同一行选项的圆心Y坐标在同一个水平带内,行间间隙是列间间隙的几倍。如果检测出的圆数量比预期少很多,第一件事不该调Sensitivity,而应检查倾斜校正后边框有没有被裁掉——imrotate的crop选项在旋转角大时会把边缘剪掉,边框定位线虽然还在,但选项圆可能离边界很近,被裁掉一圈。
3.3 涂卡判定用“圆内黑像素占比”,不直接看二值面积
圆心定位好之后,要不要用Hough圆继续去匹配“涂过的实心圆”?不建议。实心圆的边缘特征不如空心圆清晰,涂卡笔迹深浅不一,Hough圆检测在这种场景下误检率很高。常见做法是在已定位的圆心坐标上,用固定半径做一个圆形roi,统计区域内像素值低于某个阈值的比例。这个比例通常叫填充率,大于0.5判定为已涂。
function [answerMatrix, fillRatio] = readMarks(imGray, gridX, gridY, radii) % imGray: 校正后的灰度图 % gridX/gridY: locateOptionsWithHough 输出的坐标网格 % radii: 每个圆检测到的半径,用于生成掩膜 globalThr = graythresh(imGray) * 255; [h, w] = size(imGray); [xx, yy] = meshgrid(1:w, 1:h); [nRows, nCols] = size(gridX); fillRatio = zeros(nRows, nCols); answerMatrix = zeros(nRows, nCols); for r = 1:nRows for c = 1:nCols if isnan(gridX(r,c)), continue; end cx = gridX(r,c); cy = gridY(r,c); rr = radii(r,c); mask = (xx - cx).^2 + (yy - cy).^2 <= rr.^2; region = imGray(mask); fillRatio(r,c) = sum(region < globalThr) / numel(region); answerMatrix(r,c) = fillRatio(r,c) > 0.5; end end end这里用graythresh做全局阈值,对大多数手机拍照图够用,但遇到过光线从侧边打进来的图,答题卡一边亮一边暗,全局阈值会把暗侧的未涂选项误判成已涂。针对这种,先对imGray做imlocalbrighten或者分块Otsu,再进readMarks。填充率输出成fillRatio这个矩阵还有个额外好处:后面在GUI里能直接把每个选项的填充率显示出来,答题卡上有橡皮擦残迹时,一眼就能看出哪些题的0.4~0.5阈值区间比较模糊。
3.4 与标准答案比对得分的逻辑
识别出来的answerMatrix是逻辑矩阵,行是题号,列是选项序号。比对时标准答案也存成同样结构,一行里只有一个1。得分计算的细节不多,但有一个经验值得写进注释:不要用isequal直接比整行,要分别检查“多涂”和“错涂”,多涂在考试标准里通常算错,而程序里这两者都会扣分,但提示信息不同,GUI里能显示出“你这题涂了两个选项”这类明确反馈。
4. 用GUIDE把识别源码包装成带GUI界面的答题卡识别工具
4.1 界面布局:哪个控件管显示,哪个控件管参数
一块完整的答题卡识别GUI至少要有两块axes,一块显示原图,一块显示处理后的图像,再加一个uitable显示每题答案,一个edit框填标准答案串。控件之间不要有隐式依赖,用户打开图之后可以先点“参数恢复默认”,再点“开始识别”。按钮的回调函数只做数据流转,不写算法——这个边界一旦模糊,后期加批量识别功能时就得重构整个界面。
我习惯用GUIDE搭,现在新装的MATLAB默认推荐App Designer,但GUIDE生成的.m和.fig结构更适合答辩时逐行讲逻辑,而且旧版本兼容性好。指南针工具guider在较新版本里还能继续编辑GUIDE的.fig文件,不用特意装额外插件。
4.2 核心回调:从uigetfile到成绩显示
function btnRecognize_Callback(hObject, eventdata, handles) % 1. 读取文件 [file, path] = uigetfile({'*.jpg;*.png;*.bmp', '答题卡图片'}); if isequal(file, 0), return; end imgPath = fullfile(path, file); imgOrig = imread(imgPath); if size(imgOrig, 3) == 3 imgGray = rgb2gray(imgOrig); else imgGray = imgOrig; end axes(handles.axesOrig); imshow(imgOrig); title('原始图像'); % 2. 倾斜校正 [imgFixed, theta] = houghCorrectSlant(imgGray, false); set(handles.txtTheta, 'String', sprintf('%.2f', theta)); % 3. 选项定位 [gridX, gridY, radii] = locateOptionsWithHough(imgFixed); [answerMatrix, fillRatio] = readMarks(imgFixed, gridX, gridY, radii); % 4. 与标准答案比对 stdStr = get(handles.editAnswer, 'String'); [score, detail] = gradeAnswers(answerMatrix, stdStr); % 5. 结果写回界面 set(handles.uitableResult, 'Data', detail); set(handles.txtScore, 'String', sprintf('%d / %d', score, size(answerMatrix,1))); % 6. 在结果图上叠加识别出的选项位置 axes(handles.axesResult); imshow(imgFixed); hold on; viscircles([gridX(:), gridY(:)], radii(:), 'Color', 'g', 'LineWidth', 1); hold off; % 7. 刷新句柄:新增的变量不需要存,但变更过的对象属性要保留 guidata(hObject, handles); end回调里每一步对应一个封装的算法函数,逻辑顺序和命令行调脚本时完全一样。第6步用viscircles在结果图上画绿圈,这个可视化反馈对调试特别重要——如果绿圈偏了,说明imfindcircles的半径范围没跟图像分辨率走,而不是判定阈值的问题。guidata刷新放在最后,因为本次回调没有新增需要跨回调共享的数据,放这里只求个习惯一致性。
4.3 参数面板的做法:把magic number全部搬到界面上
考虑到不同摄像头拍出的图分辨率差别很大,我一般会把三个最敏感的参数放到GUI上:Canny高阈值、MinLength比例、imfindcircles的Sensitivity。界面上放三个edit框,对应三个全局变量,回调里用str2double读取并做边界检查。
function editCannyHi_Callback(hObject, eventdata, handles) val = str2double(get(hObject, 'String')); if isnan(val) || val <= 0 || val >= 1 set(hObject, 'String', '0.18'); errordlg('Canny 高阈值须在 (0,1) 范围内', '参数错误'); return; end handles.cannyHi = val; guidata(hObject, handles); end注意GUIDE里每次guidata都会保存整个handles结构体,字段多了以后性能会慢,但答题卡识别这种单帧任务完全感受不到。比起性能,更重要的是养成“回调开头读参数,回调末尾存参数”的习惯,否则用户改了参数点识别却没反应,十有八九是handles没存上。
5. 参数是怎么一步步试出来的:调参与验证技巧
5.1 三个影响最大的参数及典型现象
| 参数 | 位置 | 调大后果 | 调小后果 | 典型范围 |
|---|---|---|---|---|
| Canny高低阈值 | edge | 边框线断裂,Hough找不到线 | 版面上噪点全部变成线段干扰 | 高: 0.15~0.25,低: 0.04~0.08 |
MinLength | houghlines | 短边框被丢弃,旋转角算不出 | 选项里的横线被当成边框线 | 图像宽/高的25%~45% |
Sensitivity | imfindcircles | 题号数字、污渍被误认为选项 | 原本的圆检测不全 | 0.82~0.92 |
这三个参数之间有关联,不是独立调的。Canny阈值提高了,imfindcircles的EdgeThreshold也要相应提高,不然圆的边缘点太少。我一般流程是:先把MinLength按图像比例固定,再调Canny让边框线完整,最后调Sensitivity——这个顺序反过来的话,经常会陷入“圆检测不到,其实是边框没校正准”的死循环。
5.2 用合成测试集验证旋转鲁棒性
不用真的拿一大堆答题卡去测试,用一张标准答题卡扫描图,配合imrotate批量生成0度、±2度、±5度、±8度的测试样本,分别统计识别正确率。这个验证脚本要比人工拍照高效得多,能快速暴露houghCorrectSlant的失效边界。实测中常见规律是:倾斜角在8度以内,校正后识别率几乎没有下降;超过10度后,imrotate本身插值造成的边缘模糊开始叠加,Canny检出的边框线会变粗,导致角度估计偏差变大。这时可以降采样后再做Hough投票,角度估计用低分辨率图,选项定位用原分辨率图。
5.3 比“识别率”更实用的是输出每个选项的填充率
识别结果不要只给A/B/C/D,把fillRatio矩阵存成CSV输出。答辩时老师问到“涂卡不规范怎么处理”,直接展示某道题A选项填充率0.38、B选项0.92,B被正确判为答案,而A没有触发误判。把这个逻辑做成一个小表fillRatio < 0.4判不填,0.4~0.6判可疑,>0.6判已填,你会发现0.4~0.6这个可疑区间里集中了橡皮擦擦一半、圆珠笔出油不均、涂卡笔颜色偏浅这几类情况。有了填充率矩阵,后续不管怎么改判定规则都不用重新定位选项坐标,这也是为什么一开始就把readMarks单独拆出来的原因。
本文还有配套的精品资源,点击获取