简介:这份资源是一套基于卷积神经网络的手写字体识别完整项目,面向希望入门深度学习与图像识别的学生、开发者及Matlab使用者,帮助其理解CNN从特征提取到分类输出的全流程,并借助GUI界面直观验证识别效果。压缩包共143个文件,约25.14MB,包含106个bmp与23个jpg手写样本图像、6个p文件、3个txt说明、2个fig界面文件、1个mat数据文件、1个m主程序及1个mp4演示视频,覆盖数据集、模型代码与界面素材。项目采用Matlab 2018开发,涉及卷积层、池化层、全连接层构建,以及MNIST、CASIA等数据集的预处理、归一化与增强,并讨论卷积核大小、步长、优化算法调参和过拟合抑制思路。GUI部分支持手写板输入与实时识别,便于测试模型。目前已有2620人学习,适合作为课程设计或毕业设计的参考案例。
1. 从一张作业纸说起:这个手写汉字识别项目到底能跑出什么效果
期末交作业前一周,A同学把一张写满汉字的草稿纸拍照发过来,问能不能做个界面,点一下按钮就把上面的字认出来。这个基于卷积神经网络的手写字体识别及界面项目,就是冲着这个场景去的:用 Matlab 搭一套完整的卷积神经网络,把手写汉字图片喂进去训练,再配一个 GUI 界面,让不写代码的人也能直接上手测试。它适合两类人——一类是刚接触深度学习、想找一个能跑通全流程的入门项目;另一类是手头有手写体识别需求,想拿现成框架改一改就用的开发者。整个项目不依赖 Python 环境,从数据预处理、网络搭建、训练到界面交互,全部在 Matlab 里闭环完成,对习惯 Matlab 工具链的人来说,省掉了跨语言调试的麻烦。
2. 技术选型与整体架构:为什么用 Matlab 做卷积神经网络
2.1 选 Matlab 而不是 Python 的现实理由
很多人第一反应是手写汉字识别应该用 Python 加深度学习框架,但这个项目偏偏选了 Matlab,原因很实际。Matlab 自带 Deep Learning Toolbox,卷积层、池化层、全连接层、softmax 层都是现成的函数,不需要额外配环境。对于高校实验室或者工程现场来说,Matlab 往往是已经装好的工具,直接调用convolution2dLayer、maxPooling2dLayer这些接口就能搭网络,省去了配 CUDA、装框架、调版本兼容的时间。另一个好处是 GUI 开发,Matlab 的 App Designer 或者传统的 GUIDE 都能快速拖出界面,和训练好的网络放在同一个.m文件体系里,打包和分发都简单。
常见做法是先把网络训练脚本和界面脚本分开写,训练完把网络对象保存成.mat文件,界面加载这个文件做推理。这样训练和部署解耦,改网络结构不影响界面逻辑,改界面也不影响训练。
2.2 整体架构:三层结构各管什么
这个项目的架构可以拆成三层。第一层是数据层,负责读取手写汉字图片、做灰度化、二值化、尺寸归一化,把原始图片转成网络能吃的数值矩阵。第二层是网络层,用卷积层提取笔画特征,池化层降维,全连接层做分类,最后 softmax 输出每个类别的概率。第三层是交互层,也就是 GUI 界面,提供图片加载、识别按钮、结果显示区域,把用户操作翻译成对网络的调用。
三层之间的数据流是单向的:图片进数据层,变成张量进网络层,网络输出类别索引进交互层显示。这种分层的好处是每一层可以单独调试——数据预处理不对,看第一层的输出;网络不收敛,调第二层的超参数;界面没反应,查第三层的回调函数。
2.3 目录结构:文件怎么摆
一个典型的项目目录会包含这些内容:
project/ ├── data/ # 手写汉字图片数据集 │ ├── train/ # 训练集,按类别分文件夹 │ └── test/ # 测试集 ├── train_network.m # 网络定义与训练脚本 ├── preprocess_data.m # 数据预处理函数 ├── handwritten_gui.m # GUI 界面主程序 ├── trained_net.mat # 训练好的网络文件 └── README.md # 运行说明data/train/下面每个类别一个文件夹,文件夹名就是类别标签,Matlab 的imageDatastore能自动根据文件夹名生成标签。train_network.m里定义网络层数组、训练选项,调用trainNetwork完成训练。handwritten_gui.m是界面入口,里面加载trained_net.mat,绑定按钮回调。这种结构清晰,新手拿到手就知道先跑哪个、后跑哪个。
3. 核心模块拆解:数据预处理、网络定义与训练全流程
3.1 数据预处理:把图片变成网络能吃的矩阵
手写汉字图片的来源五花八门,尺寸不一、背景噪声不同,直接喂给网络效果很差。预处理要做三件事:灰度化、二值化、尺寸归一化。下面是一个常见的预处理函数:
function imgProcessed = preprocess_data(imgPath) % 读取图片 img = imread(imgPath); % 转灰度图,消除颜色干扰 if size(img, 3) == 3 img = rgb2gray(img); end % 二值化,把笔画和背景分开 level = graythresh(img); img = imbinarize(img, level); % 尺寸归一化到 28x28,和网络输入层匹配 img = imresize(img, [28, 28]); % 反转颜色,让笔画为白、背景为黑,符合常见训练习惯 img = imcomplement(img); % 转成 double 类型,归一化到 0-1 imgProcessed = double(img) / 255; end这段代码里,graythresh用 Otsu 方法自动找二值化阈值,比手动设阈值稳。imresize把图片统一到 28×28,这个尺寸是手写识别里常用的,太小会丢笔画细节,太大增加计算量。imcomplement反转黑白,是因为很多公开数据集是白底黑字,而网络训练时习惯黑底白字。最后归一化到 0-1,避免数值过大导致梯度爆炸。
参数说明:level是自动计算的阈值,如果某些图片二值化效果不好,可以改成固定值比如 0.5。[28, 28]要和网络输入层一致,改网络输入尺寸时这里也要同步改。
3.2 网络定义:卷积层、池化层、全连接层怎么搭
网络结构直接决定识别效果。这个项目用的是一种经典的卷积神经网络结构,适合手写汉字这种笔画复杂但尺寸不大的输入:
function layers = define_network(numClasses) layers = [ imageInputLayer([28 28 1], 'Name', 'input') convolution2dLayer(3, 16, 'Padding', 'same', 'Name', 'conv1') batchNormalizationLayer('Name', 'bn1') reluLayer('Name', 'relu1') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool1') convolution2dLayer(3, 32, 'Padding', 'same', 'Name', 'conv2') batchNormalizationLayer('Name', 'bn2') reluLayer('Name', 'relu2') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool2') convolution2dLayer(3, 64, 'Padding', 'same', 'Name', 'conv3') batchNormalizationLayer('Name', 'bn3') reluLayer('Name', 'relu3') fullyConnectedLayer(128, 'Name', 'fc1') reluLayer('Name', 'relu4') dropoutLayer(0.5, 'Name', 'dropout') fullyConnectedLayer(numClasses, 'Name', 'fc2') softmaxLayer('Name', 'softmax') classificationLayer('Name', 'output') ]; end逐段看:imageInputLayer接收 28×28 单通道图片。第一组卷积用 16 个 3×3 卷积核,Padding设为same保证输出尺寸不变,后面接批归一化和 ReLU 激活,再用 2×2 最大池化把尺寸减半。第二组卷积核数量翻到 32,第三组到 64,这是常见的“通道数递增”策略,让网络逐步提取更抽象的特征。全连接层 128 个神经元做特征整合,dropoutLayer(0.5)随机丢弃一半神经元防止过拟合,最后输出层节点数等于类别数。
参数说明:numClasses是手写汉字的类别总数,比如要识别 100 个字就传 100。卷积核数量 16、32、64 可以调,但要注意显存和训练时间。dropoutLayer的 0.5 是丢弃概率,训练集小的时候可以调高到 0.6,训练集大可以降到 0.3。
3.3 训练脚本:怎么把网络跑起来
网络定义好之后,训练脚本负责加载数据、设置训练选项、启动训练:
% 加载训练集和验证集 imdsTrain = imageDatastore('data/train', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames'); [imdsTrain, imdsVal] = splitEachLabel(imdsTrain, 0.8, 'randomized'); % 定义网络 numClasses = numel(categories(imdsTrain.Labels)); layers = define_network(numClasses); % 设置训练选项 options = trainingOptions('sgdm', ... 'InitialLearnRate', 0.01, ... 'MaxEpochs', 30, ... 'MiniBatchSize', 64, ... 'ValidationData', imdsVal, ... 'ValidationFrequency', 30, ... 'Shuffle', 'every-epoch', ... 'Plots', 'training-progress', ... 'Verbose', false); % 启动训练 net = trainNetwork(imdsTrain, layers, options); % 保存训练好的网络 save('trained_net.mat', 'net');imageDatastore自动根据文件夹名生成标签,splitEachLabel把 80% 数据划给训练、20% 给验证。trainingOptions里sgdm是随机梯度下降带动量,InitialLearnRate设 0.01 是常用起点,MaxEpochs30 轮对中小数据集够用。MiniBatchSize64 平衡内存和训练速度。Plots设为training-progress会弹出实时训练曲线,能看到准确率和损失的变化,方便判断是否收敛。
参数说明:如果训练集很大,MaxEpochs可以降到 15-20;如果准确率上不去,先把学习率降到 0.001 试试。ValidationFrequency控制每多少轮验证一次,设太小会拖慢训练。
3.4 GUI 界面:按钮回调怎么调网络
界面部分用 Matlab 的 UI 组件搭建,核心是一个“加载图片”按钮、一个“识别”按钮和一个结果显示区域:
function recognizeButtonPushed(app, event) % 加载训练好的网络 loadedData = load('trained_net.mat'); net = loadedData.net; % 获取当前显示的图片路径 imgPath = app.CurrentImagePath; if isempty(imgPath) app.ResultLabel.Text = '请先加载图片'; return; end % 预处理图片 imgProcessed = preprocess_data(imgPath); % 调整维度以匹配网络输入 [28 28 1] imgInput = reshape(imgProcessed, [28, 28, 1]); % 调用网络做预测 [predictedLabel, scores] = classify(net, imgInput); % 显示结果和置信度 confidence = max(scores) * 100; app.ResultLabel.Text = sprintf('识别结果:%s(置信度 %.1f%%)', ... string(predictedLabel), confidence); end这段回调函数做了四件事:加载网络、检查图片是否已选、预处理图片、调用classify做预测。reshape把 28×28 的矩阵变成 28×28×1 的三维数组,匹配网络输入层。classify返回预测标签和每个类别的概率,取最大概率作为置信度显示。如果置信度低于某个阈值,比如 60%,可以在界面上提示“结果不确定”,让用户重新拍图。
参数说明:app.CurrentImagePath是界面里保存的图片路径,加载图片按钮的回调里赋值。scores是各类别概率向量,长度等于类别数。置信度阈值可以根据实际需求调整,手写汉字识别一般 70% 以上比较可靠。
4. 避坑与常见问题:训练不收敛、界面报错、识别率低怎么排查
4.1 训练损失不下降,准确率卡在随机水平
现象:训练跑了几轮,损失一直在 2.3 左右(对应类别数的对数),准确率跟瞎猜差不多。
原因:最常见的是数据标签没对上。imageDatastore按文件夹名生成标签,如果文件夹名有中文、空格或者特殊字符,标签可能乱掉。另一个原因是学习率太大,梯度直接飞了。
解决:先检查imdsTrain.Labels里的类别名是否正常,用categories(imdsTrain.Labels)看一眼。如果标签有问题,把文件夹名改成英文数字。学习率从 0.01 降到 0.001 再试,同时把MiniBatchSize调小到 32,观察损失是否开始下降。
4.2 界面点识别按钮没反应,或者报维度不匹配
现象:点了“识别”按钮,Matlab 命令窗口报错,说输入维度不对,或者界面直接卡死。
原因:预处理后的图片尺寸和网络输入层不一致。比如网络定义的是 28×28,但预处理函数里imresize写成了 32×32。另一个可能是图片是 RGB 三通道,没转灰度就送进去了。
解决:在preprocess_data里加一行disp(size(imgProcessed)),确认输出是 28×28。然后在回调里reshape之前检查size(imgProcessed),如果是三维就手动转灰度。网络输入层定义和预处理尺寸必须严格一致,改一个就要改另一个。
4.3 训练集准确率高但测试集低,过拟合了
现象:训练曲线显示训练准确率 99%,验证准确率只有 70% 左右,差距很大。
原因:训练数据太少,或者网络参数太多,模型把训练样本背下来了。手写汉字如果每个类别只有几十张图,很容易过拟合。
解决:先加数据增强,用imageDataAugmenter做随机旋转、平移、缩放,扩充训练集。然后把dropoutLayer的丢弃概率从 0.5 提到 0.6 或 0.7。如果还不行,减少全连接层神经元数量,从 128 降到 64。验证集准确率连续几轮不提升就可以提前停,不用跑满MaxEpochs。
4.4 识别结果总是偏向某几个类别
现象:不管输入什么图片,识别结果总是那几个字,其他类别几乎不出现。
原因:类别样本数量不均衡。比如“一”字有 500 张图,“龘”字只有 20 张,网络会倾向于预测样本多的类别。
解决:对样本少的类别做过采样,或者用imageDatastore的replicate方法复制几份。另一种做法是在训练选项里设置ClassWeights,给样本少的类别更高权重。Matlab 的trainingOptions不直接支持类别权重,但可以在数据层做平衡,把每个类别的图片数量补齐到相近水平。
4.5 保存的网络文件加载后 classify 报错
现象:训练完保存了trained_net.mat,界面里load之后调用classify报错,说网络对象无效。
原因:保存的时候只保存了net变量,但加载的时候变量名对不上,或者保存的是layers而不是训练后的net。另一个可能是 Matlab 版本不一致,训练和加载用的版本不同。
解决:保存时用save('trained_net.mat', 'net'),加载时用loadedData = load('trained_net.mat'); net = loadedData.net;,确保变量名一致。训练和部署尽量用同一个 Matlab 版本,跨版本时用save的-v7.3选项提高兼容性。
5. 二次开发与改造:换数据集、调网络、加功能的具体做法
5.1 换数据集:从手写汉字扩展到其他字符
这个项目的网络结构不挑数据,换成手写数字、英文字母甚至简单图形都能跑。关键是把新数据按同样的目录结构摆好:data/train/下面每个类别一个文件夹,文件夹名就是标签。然后改define_network里的numClasses,它会自动根据categories(imdsTrain.Labels)计算。如果新数据图片尺寸不是 28×28,改preprocess_data里的imresize参数,同时改网络输入层的imageInputLayer([28 28 1])。注意类别数变化后,全连接层和分类层会自动适配,不用手动改。
我一般会先拿 10 个类别跑一遍,确认流程通了再扩到全部类别。这样万一有问题,排查范围小。
5.2 调网络:加深、加宽还是换优化器
如果识别率不够,有三个方向可以试。第一是加深网络,在conv3后面再加一组conv4+pool,但要注意 28×28 的输入经过三次池化已经变成 3×3,再加池化就没了。所以加深之前先把输入尺寸提到 32×32 或 64×64。第二是加宽,把卷积核数量从 16/32/64 提到 32/64/128,代价是训练变慢。第三是换优化器,把sgdm换成adam,InitialLearnRate设 0.001,通常收敛更快但可能过拟合。
下面是一个加深网络的示例片段:
% 在 conv3 之后插入第四组卷积 convolution2dLayer(3, 128, 'Padding', 'same', 'Name', 'conv4') batchNormalizationLayer('Name', 'bn4') reluLayer('Name', 'relu4') % 注意:如果输入是 28x28,这里不能再加池化层改完网络后,训练脚本不用大改,trainNetwork会自动适配。但MaxEpochs可能要增加,因为参数多了需要更多轮次收敛。
5.3 加功能:批量识别、结果导出、置信度过滤
界面可以扩展的地方很多。批量识别就是加一个“选择文件夹”按钮,遍历文件夹里所有图片,循环调用classify,把结果存到一个表格里。结果导出用writetable存成 Excel 或 CSV。置信度过滤是在显示结果前判断max(scores)是否低于阈值,低于就显示“不确定”,避免误导用户。
批量识别的核心循环大概长这样:
function batchRecognize(app, event) folderPath = uigetdir('选择图片文件夹'); if folderPath == 0 return; end fileList = dir(fullfile(folderPath, '*.png')); results = cell(numel(fileList), 2); loadedData = load('trained_net.mat'); net = loadedData.net; for i = 1:numel(fileList) imgPath = fullfile(folderPath, fileList(i).name); imgProcessed = preprocess_data(imgPath); imgInput = reshape(imgProcessed, [28, 28, 1]); [label, scores] = classify(net, imgInput); results{i, 1} = fileList(i).name; results{i, 2} = string(label); end resultTable = cell2table(results, 'VariableNames', {'文件名', '识别结果'}); writetable(resultTable, '识别结果.csv'); app.ResultLabel.Text = sprintf('批量识别完成,共 %d 张', numel(fileList)); end这段代码遍历文件夹里所有 PNG 图片,逐张识别,把文件名和结果存到 cell 数组,最后转成 table 导出 CSV。uigetdir返回 0 表示用户取消选择,直接返回。批量识别时如果图片很多,可以在循环里加drawnow刷新界面,避免界面卡死。
5.4 验证改造效果:别只看准确率
改完网络或数据后,不能只看训练曲线上的准确率。我习惯留一个独立的测试集,训练过程中完全不碰,最后用classify跑一遍,算混淆矩阵。Matlab 的confusionchart能直接画出来,一眼看出哪些类别容易混。比如“日”和“曰”这种形近字,混淆矩阵里会很明显。如果某个类别召回率特别低,就针对它补数据或者调网络。
还有一个血泪经验:每次改完保存一个新的网络文件,别覆盖旧的。命名带上日期和关键参数,比如net_28x28_conv3_20250101.mat。这样效果变差了可以回退,不用重新训练。
从那以后我每次改网络结构或者换数据集,都强制走一遍“训练→验证→混淆矩阵→保存新文件”的流程,不偷懒。希望帮到你。
本文还有配套的精品资源,点击获取