用 MATLAB 做基于 CNN 卷积神经网络的手写汉字识别,项目能不能顺利跑通,关键往往不是深度学习理论,而是整套数据流程是否完整。这套源码最值得关注的点有两个:手写汉字识别本身,以及“代码可以重新训练、可以增加其它含义”的扩展能力。它不是一份训练完就锁死的演示脚本,而是能从数据准备、标签设计、网络训练、效果验证一路重新走完的完整流程。如果你正在做课程设计、毕业设计,或者想把神经网络从手写数字识别升级到更复杂的中文场景,这篇内容可以按实操顺序带你把这套系统拆开看一遍。
1. 先弄清楚这套系统能识别什么,边界在哪
拿到任何一套“MATLAB + CNN 手写汉字识别”源码,第一件事不是急着跑训练,而是先理解它处理的任务边界。手写汉字识别和常见的 Mnist 数字识别不是同一个难度等级,也和 OCR 打印体识别完全不是一回事。
1.1 手写汉字识别和手写数字识别的关键差异
手写数字识别只有 10 个类别,字符结构简单,即使少量样本也能得到一个看起来不错的结果。手写汉字的问题在于类别数量大,即使只做常用字子集,也可能涉及几十到几百个类别。字形相近的字符非常多,例如“己、已、巳”,“木、本、末”,“日、目、田”,这些字符的区别往往只在笔画长短、封口位置等细节上。卷积神经网络需要足够的分辨率来保留这些细节,不能像处理数字那样把图像压得太小。
还有一个现实问题是手写风格差异。不同人的笔顺、倾斜角度、起笔收笔习惯都不一样,同一个人写同一个字也可能忽大忽小。所以这套系统在数据增强和样本数量上的要求,比数字识别高一个量级。
1.2 它不是通用 OCR,也不是打印体识别
很多初学者会把“手写汉字识别”和“OCR 识别文档”混为一谈。这个项目解决的通常是单字图像分类问题:输入是一张已经切好的单字图片,输出是这个字属于哪个类别。如果你给它一张整页扫描图片,它没法直接处理,因为前面还缺了文字检测和字符分割两步。
如果你拿到的源码本身只做了分类,那它的实用边界就是“已经分割好的单字图像”。要想做整页识别,需要再加版面分析、行切分、字切分模块。这一点先想清楚,后面测试时就不会用错输入。
1.3 “可以重新训练”和“可以增加其它含义”在工程上指什么
项目标题里特别提到“代码可以重新训练”,这意味着整个流程不是只保存一个模型给别人做预测,而是数据读取、网络定义、训练参数、验证评估这几个环节都能重新执行。拿到代码后,你要能回答这几个问题:数据集放在哪个目录,标签是怎么生成的,类别数量从哪来,训练结束后模型存到哪里。
“可以增加其它含义”通常有两种理解方式。一种是指可以增加新的汉字类别,也就是把字符集从固定几十个字扩展到更多字;另一种是指不局限于汉字本身,可以把每个类别映射到词义、拼音、笔画数、部首等其它标签。无论哪一种,本质上都是修改标签体系和输出类别数,网络结构本身不需要大改。
2. 环境条件先核对:MATLAB 版本、工具箱与硬件
源码能跑通,环境占一半。手写汉字识别这类项目依赖的工具箱比普通脚本多,先把环境核对清楚,比反复改代码更有效。
2.1 Deep Learning Toolbox 是硬前提
在 MATLAB 里训练卷积神经网络,必须安装 Deep Learning Toolbox。如果代码里还涉及图像灰度化、缩放、增强,通常还需要 Image Processing Toolbox。拿到源码后,第一步是在命令行运行ver查看已安装工具箱清单,确认这两个工具是否可用。
版本方面,老版本 MATLAB 对某些深度学习函数支持不完整。比如imageDataAugmenter、splitEachLabel、confusionchart这些函数是近几年才逐步加入的。如果你的版本太老,代码可能跑到某个函数时报“未定义函数”。建议先确认你的版本是否包含 Deep Learning Toolbox,再按版本调整函数写法。原始材料没有给出明确版本要求,落地时一定要以你本机的工具箱和函数支持为准。
2.2 CPU 和 GPU 怎么选
如果只是做课程设计或学习验证,CPU 完全可以跑。小数据集、几十个类别、每类几十张图,CPU 训练几分钟到几十分钟就能看到结果。但如果类别数量达到几百个,或者每类样本上千张,CPU 训练时间会变得非常难熬,这时候才需要考虑 GPU。
GPU 训练需要满足几个条件:显卡支持 CUDA、安装了 Parallel Computing Toolbox、显存足够。通常 4GB 显存跑这个规模的 CNN 问题不大,但如果你把图像分辨率拉到 128×128 或更大,同时又把 MiniBatchSize 调得很大,显存不足的报错就会频繁出现。我的建议是先 CPU 跑小样本,确认逻辑没问题后再考虑是否启用 GPU,不要一开始就把环境复杂度拉满。
2.3 数据集从哪里来,样本规模大概要多少
手写汉字识别最常用的数据来源包括公开手写汉字数据库、合成数据和自己采集的样本。公开数据集适合做基准验证,但下载后要确认格式和标签是否和你手里这套源码匹配。合成数据可以用字体渲染加随机变形生成,起步快,但和真实手写分布的差距比较大,最终效果通常不如真实样本。
自己采集的话,可以用手写板、鼠标或者手机拍照后切图,成本高一些,但胜在标签可控。无论哪种方式,建议从一个小规模子集开始:先选 10 到 20 个汉字,每类准备 50 到 100 张样本。样本太少,比如每类只有十张,即使网络很小也很容易过拟合,训练出来的准确率会忽高忽低。
3. 从原始图片到可训练数据:尺寸、标签与增强
这一部分最容易踩坑。很多训练失败不是网络结构问题,而是数据入口没有统一。下面按顺序处理。
3.1 统一尺寸、灰度化、归一化
卷积神经网络的输入层尺寸是固定的,所以所有图片进入网络前必须缩放到同一个大小。对手写汉字来说,常见做法是缩放到 48×48 或 64×64。如果图像太小,比如直接照搬 MNIST 的 28×28,相近字符的笔画细节会丢失严重;如果太大,训练时间和内存开销会明显上升,但准确率提升有限。
代码上可以先做一个统一的预处理函数:
function img = preprocessChar(imgPath, targetSize) img = imread(imgPath); if size(img, 3) == 3 img = rgb2gray(img); end img = imresize(img, targetSize); img = im2double(img); end这里把图像转成灰度单通道,再缩放到目标尺寸,最后归一化到 0 到 1 区间。注意,imresize默认插值方式对大多数样本够用,但如果你发现某些字符边缘出现明显失真,可以换成'bilinear'或者'lanczos3'试试。
3.2 用文件夹名生成标签,别手动维护数组
手写汉字识别这种多类别任务,标签最容易出错。最稳妥的做法是用文件夹名作为类别名,让imageDatastore自动读取:
imds = imageDatastore('data/hand_chars', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames');这样每个子文件夹的名字就是一个汉字类别,文件夹里的图片自动归入对应标签。以后要增加新字,只需要新建一个文件夹并放入样本,代码读取时自动多出一个类别,不需要手动维护一个长长的标签数组。这也是“可以重新训练”在数据层面最舒服的一种设计。
有一点要注意:部分老版本 MATLAB 对中文文件夹路径兼容性不太好。为了避免莫名其妙的编码问题,建议文件夹名用拼音或编号,同时在另外一个映射表里记录它对应的汉字。
3.3 训练/验证/测试划分和均衡性检查
数据不能只分训练集和测试集,最好划分成训练、验证、测试三部分。验证集用于训练过程中观察是否过拟合,测试集只在整个流程跑完后评估一次,不能反复拿它去调参数。
[imdsTrain, imdsVal, imdsTest] = splitEachLabel(imds, 0.8, 0.1, 0.1, 'randomized');划分完成后用countEachLabel检查每个类别样本数是否均衡。如果某个类别只有 5 张,而另一个类别有 100 张,训练出来的模型大概率对少数类别识别很差。要么增加样本,要么对少数类别做更多增强。
3.4 数据增强:手写需要,但不要过度
手写图像天然存在轻微旋转、缩放、位移变化,所以增强非常有用。常见做法是绕中心旋转 10 度以内,缩放 0.9 到 1.1 倍,平移几个像素。用imageDataAugmenter可以配置:
aug = imageDataAugmenter( ... 'RandRotation', [-10 10], ... 'RandXScale', [0.9 1.1], ... 'RandYScale', [0.9 1.1], ... 'RandXTranslation', [-3 3], ... 'RandYTranslation', [-3 3]);增强只应该加在训练集上,验证集和测试集保持原始分布。增强幅度也不要过大,否则会把“己”旋转成“已”的视觉形态,反而让类别边界更模糊。
注意:手写汉字识别里,增强的第一目标是模拟真实书写抖动,不是做数据炫技。小角度、小位移、小缩放就够用。
4. CNN 网络结构与训练参数:先跑稳再调优
网络结构不一定要很复杂。手写汉字虽然比数字难,但也不需要用几百层的深度网络。对入门级项目来说,一个三四层卷积的基线结构足够验证整套流程。
4.1 适合入门的一个基线网络结构
下面是一个比较常见的手写汉字识别基线结构,输入是 64×64 的灰度图:
layers = [ imageInputLayer([64 64 1]) convolution2dLayer(3, 32, 'Padding', 'same') batchNormalizationLayer reluLayer maxPooling2dLayer(2, 'Stride', 2) convolution2dLayer(3, 64, 'Padding', 'same') batchNormalizationLayer reluLayer maxPooling2dLayer(2, 'Stride', 2) convolution2dLayer(3, 128, 'Padding', 'same') batchNormalizationLayer reluLayer fullyConnectedLayer(256) reluLayer dropoutLayer(0.3) fullyConnectedLayer(numClasses) softmaxLayer classificationLayer];选 3×3 卷积加Padding为same,是为了尽量保留笔画位置信息。两次最大池化会把 64×64 降到 16×16,到这个分辨率已经足够保留汉字的大致结构。最后一层fullyConnectedLayer(numClasses)的numClasses必须和标签类别数一致,新增汉字后这个数字会自动变化,训练脚本里最好用numel(categories(imdsTrain.Labels))动态获取,而不是写死。
dropout 层的作用是降低过拟合风险。手写汉字数据集通常不大,加入 0.3 左右的 dropout 比额外堆卷积层更有效。
4.2 训练参数从哪组开始,怎么调
训练参数直接影响能否收敛。给一个比较稳定的起点:
options = trainingOptions('adam', ... 'InitialLearnRate', 1e-3, ... 'MaxEpochs', 30, ... 'MiniBatchSize', 32, ... 'Shuffle', 'every-epoch', ... 'ValidationData', augimdsVal, ... 'ValidationFrequency', 20, ... 'Plots', 'training-progress', ... 'Verbose', true);常用参数的调整方向可以参考下表:
| 参数 | 起点 | 什么情况下调 |
|---|---|---|
| InitialLearnRate | 1e-3 | 损失曲线剧烈震荡时降到 1e-4 |
| MiniBatchSize | 32 | 内存或显存不足时降到 16 |
| MaxEpochs | 30 | 验证集还在上升但 epochs 用尽时增大 |
| Shuffle | every-epoch | 保持默认即可 |
| ValidationFrequency | 20 | 样本很多时可以提高,避免频繁验证 |
| LearnRateSchedule | none | 后期收敛慢时使用 piecewise 衰减 |
学习率是最值得花时间的参数。1e-3 对大多数小规模 CNN 是合理的起点,但如果训练集或类别分布差异大,可能需要往下调。学习率太高,损失函数会在一个较大值附近反复震荡;学习率太低,训练慢而且容易停在局部最优。
4.3 怎么判断训练是否收敛、是否过拟合
训练过程中不要只看训练准确率。训练准确率能到 100%,不代表模型真的可用。更靠谱的判据是验证集准确率和损失的走势。
三种常见情况要能区分。第一种是训练和验证准确率都在上升,说明一切正常,让它继续跑。第二种是训练准确率很高、验证准确率明显偏低,这是过拟合,优先增加数据增强、加大 dropout 或增加样本量。第三种是训练和验证准确率都不高,说明模型容量不够或学习率不合适,可以加卷积层通道数、增加训练轮数,或者调整学习率。
我建议每次训练记录三样东西:初始随机种子、数据集划分方式、训练参数。否则后面复现时完全对不上结果。
5. 单张识别、批量测试和混淆矩阵:验证效果要看细节
训练结束后,不能只看一个总准确率就收工。更细的验证能告诉你模型真正学会了什么、又在哪里犯错。
5.1 单张图片识别的完整流程
单张测试是整个流程最基础的验证。拿出一张没有参与训练的手写图,先做同样的预处理,再送入网络:
img = preprocessChar('tmp/new_char.png', [64 64]); [label, scores] = classify(net, img); [maxScore, idx] = max(scores);classify返回的两个输出很有意思:第一个是预测类别,第二个是每个类别的置信度。对相近字符,比如“日”和“目”,模型的 Top1 可能猜错,但 Top2、Top3 里通常有正确结果。所以单张测试时建议把scores里前三个高分类别都打印出来,能更清楚地判断是“完全没学会”还是“细节区分还有问题”。
5.2 批量测试和混淆矩阵
批量测试要覆盖足够多样本,不能只挑几张看着像的图。用测试集跑一次,计算整体准确率,再生成混淆矩阵:
YTestPred = classify(net, augimdsTest); YTestTruth = imdsTest.Labels; accuracy = mean(YTestPred == YTestTruth); cm = confusionchart(YTestTruth, YTestPred);混淆矩阵是最值得看的结果。如果某个类被反复错分成另一个类,比如“木”经常被认成“本”,可能的原因包括:这两个类训练样本不足、图像分辨率太低、或者样本标注本身有误。如果某个类的对角线数值明显偏低,优先检查这个类的样本数量和标注质量。如果测试集整体准确率低,先回看输入预处理,再考虑网络结构。
5.3 结果保存和模型复用
训练好的模型要保存下来,方便以后直接使用:
save('cnn_hand_char_net.mat', 'net');下次使用时只要加载模型,再配合预处理函数就可以做预测,不需要重新训练。实际项目里,我还会把每张测试图片的预测结果、真实标签、置信度一起写入表格,统一保存到输出目录,方便后面做错例分析。
T = table(YTestTruth, YTestPred, scores, 'VariableNames', {'Truth', 'Pred', 'Score'}); writetable(T, 'test_results.csv');6. 增加类别、增加含义、重新训练:核心扩展路径
“代码可以重新训练”是这个项目最大的价值点。下面拆成具体的操作步骤。
6.1 新增一个手写汉字类别的操作顺序
新增一个汉字的流程并不复杂,重点在步骤是否完整:
- 在数据根目录下新建一个文件夹,名字用拼音或编号,例如
mu2,并在映射表里记录它对应“木”。 - 放入足够多的手写样本,数量尽量和其他类别接近。
- 运行数据读取代码,用
unique(imds.Labels)确认新类别已经进入标签集合。 - 重新训练网络,最后分类层的
numClasses会自动更新。 - 用一个测试脚本同时测试旧类别和新类别,确认新增类别没有把旧类别准确率拉下去。
如果源码里numClasses是写死的,必须在训练前改掉。这也是我为什么建议动态获取类别数的原因。
6.2 全部重训和微调怎么选择
新增类别后有两种训练方式。
第一种是全量重训。所有类别、所有样本从头开始训练,优点是模型整体更均衡,缺点是训练时间长。第二种是微调。加载已训练好的模型,把最后一层全连接层替换成新的类别数,然后用一个更小的学习率继续训练。微调速度快,但如果新增类别较多,或者旧样本数量不足,模型可能对旧类别产生“遗忘”。
我的经验是:新增类别数量少、总量占比小于 10% 时,可以优先试微调;新增类别较多,或者新旧类别样本差异很大时,直接全量重训更省心。不管用哪种方式,都要用旧类别测试集做回归测试。
6.3 “增加其它含义”的两类落地方式
标题提到的“增加其它含义”,在实际工程里可以落到两个方向。
方向一是改标签映射。不一定把文件夹名当成最终输出,而是先把汉字识别出来,再通过一个映射表把汉字映射到词义、拼音、笔画数、部首等属性。这种方式不需要重新训练模型,只需要在识别之后加一层查找逻辑。
方向二是直接把训练标签改成其它属性。比如不再让模型判断“这是哪个字”,而是判断“这个字属于哪个部首”“这个字的拼音首字母是什么”。这需要修改数据读取阶段的标签生成方式,同时把网络的输出类别改成目标属性集合。卷积层和池化层可以复用,甚至可以直接用已训练模型做迁移学习。
6.4 重训练后的回归检查
重训练不是跑完就结束。每次训练完,固定做一次回归检查:把旧类别测试集和新类别测试集合并,统一跑一遍,比较新增前后整体准确率、旧类别准确率、新类别准确率。如果旧类别准确率明显下降,说明新类别加入影响了原始特征分布,要增加旧类别数据或者回滚到之前模型。
这套检查成本不高,但对课程设计和后续扩展非常有用。它能让你判断每一次改动是进步还是倒退。
7. 实测中的典型报错和排查顺序
运行这类项目时,报错几乎是必然的。关键是要有一个稳定的排查顺序,而不是看到报错就乱猜。
7.1 一个稳定的排查链路
遇到问题按下面顺序排查:
- 复现最小错误。先用一张图片或一个最小的 datastore 跑一遍,把问题范围缩小。
- 看输入。检查图像尺寸、通道数、灰度范围、是否黑白反转。
- 看标签。检查类别数量、文件夹名、标签是否和真实内容一致。
- 看网络。逐层计算尺寸,确认全连接层输出和类别数匹配。
- 看资源。观察内存、显存、CPU 占用情况。
- 看参数。先排查学习率、批次大小、训练轮数。
- 看环境。确认工具箱版本、函数是否在当前 MATLAB 版本中可用。
这个顺序的核心思想是“从数据到代码,从显性错误到隐性错误”。绝大多数训练问题都出在前面几步。
7.2 高频错误和修复方向
| 错误或现象 | 可能原因 | 优先处理 |
|---|---|---|
| 提示输入图像尺寸不一致 | 数据入口没有统一缩放 | 检查预处理函数是否应用到所有图片 |
| 全连接层输出维度不匹配 | 类别数或中间层维度写死 | 动态获取类别数,检查网络各层尺寸 |
| 内存或显存不足 | 网络层数多、批次大、图像大 | 调小 MiniBatchSize 或图像分辨率 |
| 验证准确率接近 1/类别数 | 标签错、图像极性反、学习率不当 | 先检查 datastore 标签,再看训练曲线 |
| 新类别全部识别失败 | 新类别样本太少或未参与训练 | 补充样本,确认标签进入数据集 |
| 训练曲线震荡严重 | 学习率偏高或批次过小 | 调低学习率,保持批次稳定 |
图像极性是一个很隐蔽的问题。手写汉字数据集通常是黑字白底,训练时模型学到了这个分布。如果测试图是白字黑底,准确率会断崖式下跌,而很多人第一反应是去调网络结构,浪费大量时间。
7.3 容易忽略但影响很大的细节
路径和权限是最容易被忽视的。中文路径、带空格的目录、某些云盘同步目录,都可能导致图片读取失败或顺序错乱。测试时尽量把整个项目放在纯英文路径下。
另一个细节是保持实验变量的单一性。每次只改一个参数,比如这次只改学习率,下次只改网络层数,不要同时改多个变量,否则结果无法归因。
日志和输出目录要从一开始就整理好。训练时间长的项目,如果模型命名不清晰、参数记录不完整,几天后再看就分不清哪个模型对应哪组参数了。
8. 后续扩展方向:从单字到词组、语义映射和工具封装
如果单字识别已经稳定,还能继续往几个方向扩展。
8.1 词组识别需要先解决分割问题
单字识别只能处理切好的单字图。要识别一个完整的词组或短句,需要先做文字检测和字符分割。分割的难点在于手写字符高低错落,字符之间可能粘连,需要结合投影分析和连通域算法。单字识别模型本身不用大改,重点在前处理。
8.2 语义映射表让识别结果有“含义”
在识别结果后面接一个查找表,可以把“识别出这个字”升级成“知道这个字是什么意思、怎么读、属于什么类别”。例如识别出“日”,可以映射到“太阳、白天、时间单位”等含义。这正好对应标题里“增加其它含义”