简介:基于Matlab实现的答题卡识别系统毕业设计资源,面向计算机、电子信息、数学等专业学生,适用于课程设计、期末大作业或毕业设计参考。项目包含完整GUI界面与图像处理流程,涵盖高斯滤波、图像归一化、中值中心定位、Hough变换、角度计算、旋转校正、平滑处理及识别主程序等多个功能模块,配有9张测试图片与mat数据文件,可帮助读者理解从图像预处理到判分的完整链路。压缩包共24个文件,包含11个m源码、9个jpg图片、2个mat数据、1个asv备份及1个fig界面文件,整体大小28.47MB,结构清晰便于查阅。已有647人学习下载,适合需要快速上手答题卡识别项目、学习Matlab GUI编程与图像处理算法综合应用的读者,作为高分项目参考资料具有较好借鉴价值。
1. 为什么拿 Matlab 做答题卡识别
第一次拆这类答题卡识别系统源码,很多人以为难点在“怎么判断有没有涂黑”,实际接手这套基于 Matlab 实现答题卡识别的毕业设计源码后会发现,真正决定识别率的是一张照片从“拍歪的现场图”变回“规整栅格图”的那段图像处理链路。它解决的是很具体的场景:输入手机拍摄或扫描得到的答题卡图片,完成灰度化、高斯滤波、霍夫变换、透视校正、选项网格定位、涂写判断,再与 answer.mat 或 answer2.mat 中的标准答案比对,输出得分和错题列表,并通过 GUI 界面把整个流程可视化地演示出来。适合正在做 Matlab 课程设计、期末大作业或毕业设计的电子信息、计算机类专业学生。新手可以直接跑通 main.m 到 main3.m 的完整链路,有基础的人则可以基于 findMidCenter.m、Hough_Process.m 这些模块改成自己的版式。
2. 答题卡图像的预处理与透视校正:从灰度化到 Hough 变换
2.1 灰度化、平滑与二值化的参数选择
PicSrc 里的 1.jpg、2.jpg 这类原图,一般是 300dpi 扫描图或者手机平拍摄像头图。彩色通道对后续找边缘和连通域没有额外收益,反而会增加自适应阈值的计算量,所以第一步统一转灰度。gaussianFilter.m 在这里的作用是抑制传感器噪声,直接用imgaussfilt也能达到同样效果。需要说明的是,高斯核不是越大越好,sigma 太大会把铅笔灰的浅痕迹也抹平,导致后面的填充率统计偏低。
img = imread('1.jpg'); if size(img, 3) == 3 gray = rgb2gray(img); else gray = img; end % 高斯平滑,sigma=1.2 对 300dpi 答题卡比较合适 smooth = imgaussfilt(gray, 1.2); % 自适应阈值:前景(涂写区域)比背景暗 bw = imbinarize(smooth, 'adaptive', ... 'ForegroundPolarity', 'dark', ... 'Sensitivity', 0.55); % 答题卡背景是白色,标记和涂写是黑色,取反后方便后续 regionprops BW = ~bw;imbinarize的adaptive模式会为每个像素计算邻域阈值,适合手机翻拍造成的光照不均。Sensitivity越大越容易把浅灰色铅笔痕迹也判成黑,推荐 0.45~0.6,90% 的涂卡情况都能落在这个区间。ForegroundPolarity设为dark,是告诉函数“我们要检测的是暗色前景”。最后取反是因为答题卡大面积白色、标记是黑色,后续regionprops、bwareaopen和形态学操作都默认前景为 1。如果 9.jpg 这类压缩较重的手机图出现边缘模糊,可以先imsharpen,但强度不要超过 1,否则会在文字边缘产生白色噪点环。
2.2 用 Hough 变换检测答题卡边界与旋转校正
Hough_Process.m 实际就是把“找边缘直线”和“得到旋转角”封装在一起。答题卡通常有两条以上长直线边缘,即使在复杂背景下也会保持高响应;Hough 把边缘点投票到参数空间,输出直线方程,比直接对边缘点做最小二乘拟合更抗孤立点干扰。实现上分四步:Canny 边缘、hough 投票、峰值提取、houghlines 画线。
edges = edge(BW, 'Canny', [0.1 0.25]); [H, T, R] = hough(edges); peaks = houghpeaks(H, 30, 'Threshold', ceil(0.35 * max(H(:)))); lines = houghlines(edges, T, R, peaks, 'FillGap', 80, 'MinLength', 150); % 同一张卡的水平边 theta 基本相同,取中位数抗离群 thetaList = [lines.theta]; angleToRotate = median(thetaList); BWr = imrotate(BW, -angleToRotate, 'bilinear', 'crop');Canny 阈值[0.1 0.25]比默认偏松,因为答题卡边缘在二值图里非常锐利,阈值太严格会丢失定位块附近的短边。houghpeaks只取投票前 30 的峰,Threshold设为最大投票数的 35%,筛掉由噪点形成的短线。FillGap=80允许同一直线上的缺口最长为 80 像素,避免边缘被印刷的定位标记截断成多段。角度取中位数而不是均值,原因是偶然检测到桌面边缘或纸阴影时会产生明显偏角,中位数能容忍约 1/3 的异常线。
注意:Hough 返回的
theta是直线法向与 x 轴的夹角,不是纸张长边与水平线的夹角,所以旋转符号容易写反。我一般先打印angleToRotate,如果原图右倾但旋转后更歪,就把-angleToRotate改成+median(thetaList)+90。另外,imrotate的crop参数会裁掉旋转后露出的边角,后续定位标记时不要依赖图像边缘。
2.3 透视校正:把斜视角拍摄的答题卡拉正
旋转校正只解决平面内旋转。手机不是正上方拍摄时,试卷会呈现梯形或四边形透视变形,这时必须做透视校正。源码里的 Image_Rotate.m 覆盖了旋转部分,更完整的识别系统还会保留一个透视入口。常见做法是用fitgeotrans把四个定位角标映射到标准矩形。
% 已通过 regionprops 得到角标中心 corners = [x1 y1; x2 y2; x3 y3; x4 y4] % 标准答题卡对应坐标,单位像素 fixed = [0 0; 2100 0; 2100 2800; 0 2800]; tform = fitgeotrans(corners, fixed, 'projective'); Icorrect = imwarp(BWr, tform, ... 'OutputView', imref2d([2800 2100]));fitgeotrans至少需要 4 对匹配点,projective变换能描述任意四边形到矩形的映射,比affine只能处理平行四边形的能力更强。检测角标时,我会用regionprops(BWr, 'Area', 'Centroid')筛选面积在 800~5000 像素内的连通域,再利用它们到图像质心的距离排序。如果答题卡有黑色边框,角标会和边框连成一个连通域,需要先imerode一次,或者在先验位置裁剪固定区域后再做检测。完成这一步后,后续网格坐标可以直接按固定像素步长切分,不必每张图重新学习版式。
3. 选项区域定位与分割:从定位标记到网格坐标
3.1 用 findMidCenter 语义理解定位标记
findMidCenter.m 名字很直白:找到图形区域的中心。在答题卡识别中,这个中心是建立坐标系的原点,而不是简单的几何质心。我读源码时看到它内部使用了regionprops和Centroid,这与系统需要把物理坐标映射到答题卡版式的需求一致。大致等价于以下代码:
stats = regionprops(BW, 'Area', 'Centroid', 'BoundingBox'); candidate = stats([stats.Area] > 500 & [stats.Area] < 5000); centers = reshape([candidate.Centroid], 2, [])'; % 调试时打印面积分布,确认定位块和涂写块的面积区间 areas = [candidate.Area]; disp(areas);面积区间是关键。扫描后涂写黑块面积一般在 300~600 像素,定位块会设计得更大;如果区间下限设太小,会把噪点也算进来。中心点找到后,建议按“左上、右上、左下、右下”重新排序,否则fitgeotrans的坐标对应关系会错乱。我常用sortrows(centers, [2 1])先粗排,再通过质心到整体包围盒四角距离做精确匹配。
3.2 行列切割:题号步长与选项宽高映射
定位完成后,识别系统进入最关键的一步:网格坐标生成。这里不能直接固定行列等分,因为印刷和扫描过程会产生 3~5 个像素的偏移,直接等分会导致选项框切偏。我会先建一张参数表,把版式信息写清楚,再用两个定位标记之间的距离计算步长。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| rows | 20 | 题号总行数,来自印刷版式 |
| cols | 4 | A~D 选项数,5 个时改为 A~E |
| cellW | 60~80 px | 单个选项区域宽度 |
| cellH | 55~75 px | 单个选项区域高度 |
| stepX | (xRight - xLeft) / cols | 水平基准步长 |
| stepY | (yBottom - yTop) / rows | 垂直基准步长 |
切割代码:
% 以左上定位块中心 (x0,y0) 为起点 for q = 1:rows for opt = 1:cols x1 = round(x0 + (opt-1) * stepX + padX); y1 = round(y0 + (q-1) * stepY + padY); x2 = round(x1 + cellW); y2 = round(y1 + cellH); roi{q, opt} = BW(y1:y2, x1:x2); end endpadX/padY用于去掉印刷框线本身的干扰,建议设成round(cellW*0.15)和round(cellH*0.12)。切割出的 ROI 直接保存为元胞数组,后续涂写判断就不需要再在全图中做运算。这里一个常见误用是直接imcrop后尺寸不一致,导致cellfun报尺寸不匹配;我习惯把所有 ROI 统一插值到 64×64,即roi{q, opt} = imresize(roi{q, opt}, [64 64]),这样后续特征长度固定,也方便批量处理。
3.3 当定位标记缺失时怎么办
PicSrc 里部分图片存在裁切或阴影,定位标记可能被截掉半个,regionprops会漏检。常用补救方式有两种:第一种是检测整张图中最长的水平线和竖直线,用交点代替缺失点;第二种是利用 Hough 直线与图像边界的交点估算出扩展角。推荐优先补点而不是放弃,因为毕业设计答辩时经常用缺角图片测试鲁棒性。如果某个角标缺失,我会把该角坐标设置为相邻两条边缘直线在 200 像素范围内的像素坐标最大值,并记录一个warning,在 GUI 状态栏提示“已自动补全定位点,精度可能下降”。
4. 涂写识别与评分算法:像素密度、粘连处理和答案比对
4.1 用填充率而不是“是否全黑”判断涂写
拿到选项 ROI 后,直接统计黑色像素占比是最稳定的方法。常见阈值:涂写区域内黑色像素占比超过 35% 判为已涂,低于 15% 判定为空白,15%~35% 视为可疑。大多数 2B 铅笔在 300dpi 下填充率在 0.5 以上,HB 铅笔或擦除不干净会在 0.2~0.3 之间。实现如下:
filled = zeros(rows, cols); for q = 1:rows for opt = 1:cols cell = roi{q, opt}; ratio = sum(cell(:) > 0) / numel(cell); filled(q, opt) = ratio; end end mark = (filled > 0.35);sum(cell(:) > 0)统计 ROI 中前景像素总数,numel(cell)是区域面积。这里不推荐对每个 ROI 分别graythresh,因为全空白的 ROI 用大津法会把噪声阈值抬得很高,导致误判。如果需要自适应,我一般只对整张图算一个 Otsu 阈值,然后每个 ROI 用它的 0.8 倍作为判断阈值。
4.2 单选、多选、未涂和模糊涂写分别处理
答题卡批改最容易出逻辑问题的是把单选、多选混合在一起处理。单选应该取每行填充率最高的选项;多选则要把所有超过阈值的选项都选中;如果出现填充率在 0.15 到 0.35 之间的选项,应该标记为“疑题”而不是直接扣分。代码如下:
singleChoice = zeros(rows, 1); multiChoice = cell(rows, 1); for q = 1:rows rowMark = filled(q, :); over35 = find(rowMark > 0.35); over15 = find(rowMark > 0.15 & rowMark <= 0.35); if ~isempty(over35) [~, idxMax] = max(rowMark); singleChoice(q) = idxMax; multiChoice{q} = over35; elseif isempty(over15) singleChoice(q) = 0; % 未涂 multiChoice{q} = []; else % 介于 15% 和 35% 之间:交给人工复核 singleChoice(q) = NaN; multiChoice{q} = over15; end end0.35和0.15是我基于 PicSrc 里多张图统计出来的经验值。如果切换低分辨率扫描,可以先把每行filled矩阵打印到 Excel,观察涂写与空白的分布区间再微调。NaN在后续比对中会作为“疑题”处理,在 GUI 中显示为黄色,不参与得分,也不判错。
4.3 与 answer.mat 比对,得到得分和错题列表
answer.mat 和 answer2.mat 存放标准答案,常见格式是 20×1 的数值数组,也可能包含学号和得分字段。这里给出兼容单选比对的代码:
load('answer.mat'); % 假设变量名为 sd,类型为 20x1 数值数组 score = 0; errorList = []; for q = 1:rows if isnan(singleChoice(q)) errorList(end+1) = q; % 疑题不计分不扣分 continue; end if singleChoice(q) == sd(q) score = score + 5; % 每题 5 分 else errorList(end+1) = q; end end fprintf('得分:%d / %d\n', score, rows*5);与答案比对前先排除NaN,避免isequal(NaN, 3)永远返回 0。错题通过errorList存题号,便于后续生成 Excel 错题报告。如果标准答案本身是多选结构,可以改用cellfun(@(x,y) isequal(x,y), multiChoice, answerList)逐题比对。
下面是一张我调试时经常对照的故障表:
| 现象 | 原因 | 处理 |
|---|---|---|
| 某一行全部误判 | 网格偏移累计误差 | 用相邻定位标记重新计算 stepY |
| 所有选项都偏黑 | 背景阈值取了噪点 | 把 Sensitivity 降低 0.05 |
| 双选题漏选 | 涂写接近阈值下界 | 对 ROI 做 imdilate 再统计 |
| 印刷字符被当涂黑 | 字符面积接近涂写块 | 按连通域面积过滤,设置最大面积上限 |
5. 把识别流程包装成 GUI 以及真实试卷上的容错技巧
5.1 MYGUI.fig 的回调设计
MYGUI.fig 是界面文件,MYGUI.m 是主程序。典型布局为:左边坐标轴显示原图,右边坐标轴显示识别结果,中间一排按钮“读入图像、开始识别、重置”,下方文本框显示得分和疑题。按钮回调用guidata保存数据,避免每次点击都重新读取图片。
function btnLoad_Callback(hObject, eventdata, handles) [filename, pathname] = uigetfile({'*.jpg;*.png', '图片文件'}); if isequal(filename, 0), return; end handles.origin = imread(fullfile(pathname, filename)); axes(handles.axesOrigin); imshow(handles.origin); guidata(hObject, handles); end function btnRun_Callback(hObject, eventdata, handles) if ~isfield(handles, 'origin'), return; end handles.result = main3(handles.origin); axes(handles.axesResult); imshow(handles.result.outputImg); set(handles.textScore, 'String', sprintf('得分:%.1f', handles.result.score)); guidata(hObject, handles); endmain3.m在本资源里相当于合并后的处理入口,接收图像矩阵,返回包含outputImg和score的结果结构体。按钮回调里不要堆大量图像处理代码,否则 GUIDE 生成的 m 文件会很乱,后续定位问题也不方便。
5.2 四个提升“答辩通过率”的小技巧
第一,形态学开运算消除孤立噪点。BW2 = imopen(BW, strel('disk', 2))只去掉 1~2 像素的噪声,不会碰涂写区域,对扫描产生的碎点特别有效。
第二,涂写不饱满时先膨胀再统计。roi_dil = imdilate(roi, strel('disk', 3))会让浅涂区域的填充率提升 5~10 个百分点。具体做法是同时计算原始 ROI 和膨胀后 ROI 的填充率,只要膨胀后填充率超过 0.5 就判为已涂,这样可以兼容擦除不干净的情况。
第三,倾斜角度校验放在 GUI 状态栏。处理前打印Compute_Angle.m得到的角度,如果绝对值大于 5 度,就提示用户重新拍照,而不是强行校正。大角度透视会直接切断选项框,校正后依然会漏选。
第四,把识别结果写成 Excel 表格。使用writetable将题号、标准答案、学生答案、是否匹配四列导出,这是毕业设计答辩时最直观的产品化证明。调整阈值时,我会把“疑题”数量作为指标,若疑题超过总题数 3%,优先检查网格步长而不是继续调阈值。
本文还有配套的精品资源,点击获取