MATLAB Hough变换答题卡识别:倾斜校正与圆检测定位
简介基于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 检测暗色圆ObjectPolaritydark 表示找比背景暗的圆 % 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.08MinLengthhoughlines短边框被丢弃旋转角算不出选项里的横线被当成边框线图像宽/高的25%~45%Sensitivityimfindcircles题号数字、污渍被误认为选项原本的圆检测不全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.92B被正确判为答案而A没有触发误判。把这个逻辑做成一个小表fillRatio 0.4判不填0.4~0.6判可疑0.6判已填你会发现0.4~0.6这个可疑区间里集中了橡皮擦擦一半、圆珠笔出油不均、涂卡笔颜色偏浅这几类情况。有了填充率矩阵后续不管怎么改判定规则都不用重新定位选项坐标这也是为什么一开始就把readMarks单独拆出来的原因。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →