字符识别是个特别适合拿来入门神经网络的题目:数据好搞、任务直观、效果一眼就能看出来,而且背后那一套“采集图像→预处理→训练模型→部署测试”的流程,放到车牌识别、票据识别、答题卡批改、快递单号录入这些真实场景里全都用得上。这篇博文我就讲讲基于神经网络的字符识别如何在Matlab里从零跑通,并附上可复用的Matlab代码。内容主要面向两类人:一类是做毕业设计或课设、需要快速出结果的在校学生;另一类是刚接触图像识别、想把手写数字识别当练手项目的工程师。我会把网络选型、数据预处理、训练参数、踩坑排查和最后的部署测试都串起来讲,尽量让不同基础的人都能直接照着做。
1. 字符识别这件事,为什么值得亲自动手做一遍
1.1 字符识别不是小众需求
先说说我为什么对“字符识别”这个主题印象深。前阵子有人问我,说想做一套自动识别快递面单上电话号码的系统,问我用什么方案最省事。我当时给的答案是:核心模块其实就是字符识别——先把一串数字从复杂的背景里切出来,再逐个字符去分类,最后按顺序拼回去。类似的场景还有试卷客观题批改里的考号识别、工厂流水线上D打印的序列号读取、手机App拍银行卡号自动填充……它们的共同点都是“把图像里的字母和数字变成计算机能处理的文本”。
这也就是所谓的OCR大类里的一个子方向:字符识别。神经网络出现之前,大家做字符识别主要靠模板匹配和手工设计特征,比如提取笔画密度、投影直方图、区域的拓扑特征,再丢给SVM或Adaboost分类。说句公道话,这些方法在小规模、规整的印刷体场景里效果并不差,但一旦遇到歪斜、模糊、光照不均、字体变化,手工特征就开始吃力了。而神经网络本质上干的事情是:把“特征提取”和“分类器”一起端到端地学好,你喂给它的不是人工设计的特征,而是原始像素。这也是这个题目放到现在依然值得做的原因。
1.2 传统方法和神经网络方法的差异在哪
我经常用一句话跟人解释这个差异:传统方法像是你给一个没见过世面的人列了一堆“如何识别数字”的手写规则,告诉他笔画怎么交叉、哪里封闭、哪里凸起,规则写满好几页,换一种字体或者模糊一点就失效。神经网络则是直接给他看一万张带标签的数字图片,让他自己观察规律,最后他可能说不清自己到底掌握了什么规律,但新图来了他就是能认出来。
落到字符识别这个具体任务上,神经网络的优势主要有三点:
- 端到端建模:输入图片,直接输出类别概率,中间不需要人工设计特征。
- 泛化能力好:对轻微旋转、平移、噪声的抗性,比固定模板强得多。
- 可复用到其他字符集:把数字换成英文字母、车牌汉字甚至验证码,只需要换数据集,网络结构几乎不用动。
而在所有能实现神经网络的工具里,Matlab又是个很特殊的存在:它不用装一堆依赖包、不用管CUDA和Python环境打架,装好工具箱、写几十行代码就能看到训练曲线动起来。对于教学演示、毕设验证、快速原型来说,这比Python系工具链省心不少。所以我这篇文章的代码方案完全基于Matlab的深度学习工具箱,版本要求不高,后面我会说到具体的版本检查方法。
2. 环境与数据准备:Matlab里最容易被忽略的几个前置细节
2.1 版本与工具箱检查
别小看这一步,环境问题能卡掉一半的新手。基于神经网络的字符识别在Matlab里主要依赖这几个工具箱:
| 工具箱 | 作用 |
|---|---|
| Deep Learning Toolbox | 提供层的定义、trainNetwork训练函数、trainingOptions配置 |
| Image Processing Toolbox | 灰度化、二值化、形态学处理等图像预处理 |
| Parallel Computing Toolbox | 可选,GPU训练和并行池相关功能依赖它 |
检查方法很简单,在Matlab命令行输入:
ver % 列出所有已安装工具箱输出列表里如果找不到Deep Learning Toolbox,那就得先安装或者换一台装了完整工具箱的机器。用Deep Learning Toolbox训练网络的最低版本要求不算低,建议直接用R2019b之后的版本,因为R2019b开始trainNetwork对数据存储、层定义的兼容性比较完善,R2021a之后用起来更舒服。
还有一个常用的检查命令:
license('test', 'Deep_Learning_Toolbox')返回1表示当前许可证可用。这一步最好开跑前就确认好,不然代码敲到一半才发现缺工具箱,心态容易崩。
2.2 数据集从哪来
做字符识别,有两条路:用公开数据集,或者自己造数据。公开数据集最经典的是手写数字MNIST,它有六万张训练图和一万张测试图,每张是28x28的灰度图,正好对应0到9共十个类别。拿这个数据集跑通流程,相当于拿到了字符识别项目的“最小可用版本”。
自己造数据的情况更多出现在毕设和真实项目里:比如你要识别的是印刷体英文字母、特定字体的身份证号,或者某些特殊字符集。这时候我建议不要一张张去另存图片,而是写个小脚本批量生成或批量采集:
% 批量读取用户准备好的字符图片文件夹 imds = imageDatastore(fullfile(pwd, 'charData'), ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames');注意这个imageDatastore的用法:它会把charData下面每个子文件夹当作一个类别,文件夹的名字自动变成标签。所以数据目录的组织方式应该是这样:
charData/ ├── 0/ │ ├── 0_001.jpg │ ├── 0_002.jpg │ └── ... ├── 1/ │ ├── 1_001.jpg │ └── ... └── ...每个类别下的样本尽量多一些,最低也别低于一两百张,不然训练出来的模型很容易过拟合。
2.3 数据预处理的关键点
如果你用的是MNIST这类已经规范化好的数据集,预处理任务就轻松很多。但如果你是自己拍的照片,那预处理就决定了最终识别率的上限,甚至比网络结构还重要。我这里给一个通用的字符图片预处理函数,把任意输入图片统一成28x28的单通道灰度图:
function img = preprocessChar(im) % 输入:RGB或灰度图像 % 输出:28x28x1的灰度图,用于输入神经网络 if size(im, 3) == 3 im = rgb2gray(im); end % 自适应二值化:把前景字符和背景分开 bw = imbinarize(im, 'adaptive', 'ForegroundPolarity', 'dark'); % 统一尺寸 imgResized = imresize(bw, [28 28]); % 注意极性:MNIST是白字黑底,若你的图片是黑字白底则取反 imgResized = imcomplement(imgResized); img = single(imgResized); end这个函数里藏着一个特别容易踩的坑:二值化极性。MNIST数据集里字符是白色的、背景是黑色的;而你自己用手机拍的字符,绝大多数是黑字白底。如果你不取反,训练和预测时的输入分布不一致,后面测试准确率会突然掉到接近随机水平。这也是我在后面踩坑章节里会重点展开的一个问题。
还有一点需要提醒:imbinarize自适应的阈值在图片光照不均匀时比固定阈值好用,但如果在某些特别干净的黑白合成图上,直接用固定阈值imbinarize(im, 0.5)反而更快更稳。你可以根据自己数据的实际情况选,判断标准是看二值化结果里字符是否完整、背景是否干净。
3. 网络搭建与训练:从MLP到CNN的选择逻辑和Matlab实现
3.1 网络结构的选择逻辑
训练字符识别,最基础的是全连接神经网络(MLP)。把28x28的图片拉成一维,就是784个输入特征;中间加两层全连接层;输出层用10个神经元对应0到9。这个结构在MNIST上跑出97%以上的准确率并不难,但它有个天然的劣势:它把图片的二维空间结构完全丢掉了,每个像素位置都被当成彼此独立的特征。人眼能轻松识别一个数字,靠的是笔画之间的相对位置关系,而不是某个孤立的像素值。
CNN(卷积神经网络)则保留二维空间信息,通过卷积核在图像上滑动来提取局部模式,比如边缘、弧线、交叉点。之后再逐层组合成更高级的特征。对于字符识别这种任务,CNN几乎是默认选择:参数更少、准确率更高、对字符轻微形变的鲁棒性也更好。那为什么我还要先讲MLP?因为MLP简单,适合用来理解训练过程;等你把全流程跑通了,再替换成CNN就是改几行层定义的事。
我建议的路线是:先用一个简单的CNN把流程跑通,确认数据和训练代码没问题,然后再回头尝试更深的模型。这个“从简单到复杂”的顺序能帮你把变量控制在最少。
3.2 完整训练代码
下面这套代码就是一个可运行的最小CNN示例,网络结构包含卷积层、池化层、全连接层和输出层:
% 定义网络结构 layers = [ imageInputLayer([28 28 1], 'Name', 'input') convolution2dLayer(3, 32, 'Padding', 'same', 'Name', 'conv1') reluLayer('Name', 'relu1') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool1') convolution2dLayer(3, 64, 'Padding', 'same', 'Name', 'conv2') reluLayer('Name', 'relu2') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool2') fullyConnectedLayer(128, 'Name', 'fc1') reluLayer('Name', 'relu3') fullyConnectedLayer(10, 'Name', 'fc_out') softmaxLayer('Name', 'softmax') classificationLayer('Name', 'output') ]; % 训练选项 options = trainingOptions('sgdm', ... 'InitialLearnRate', 0.01, ... 'MaxEpochs', 10, ... 'MiniBatchSize', 128, ... 'Shuffle', 'every-epoch', ... 'ValidationData', auVal, ... 'ValidationFrequency', 20, ... 'Verbose', true, ... 'Plots', 'training-progress'); % 训练网络 net = trainNetwork(auTrain, layers, options);这里有个细节:auTrain和auVal是什么?它们是增强图像数据存储,作用是统一一组图片的尺寸。如果你的图片本身就是28x28,直接用imageDatastore也行;但不用尺寸不一的图片硬训,imageInputLayer会直接报错。统一尺寸的做法如下:
auTrain = augmentedImageDatastore([28 28], trainImds); auVal = augmentedImageDatastore([28 28], valImds);augmentedImageDatastore会在读取每张图时自动resize到[28 28],相当于把尺寸不一致的问题提前消化掉。在实际图像识别项目里,这个对象很适合用来做在线数据增强,比如随机旋转、平移、翻转,但做手写数字识别时别随便用平移和旋转——数字的语义和位置无关,但字符本身的方向是有意义的。
3.3 trainingOptions参数调优思路
很多人拿到trainingOptions只会照抄参数,不知道每个参数在干嘛,结果训练不收敛的时候也不知道改哪。我挑几个主要的说下我的经验:
- InitialLearnRate(初始学习率):MLP和简单CNN用0.01到0.03是安全区间。学习率太大会导致损失函数发散,太小会让训练在有限epochs里走不到最优。如果损失曲线长期不下降,先考虑调低学习率而不是盲目加层数。
- MiniBatchSize:一次性喂给网络的样本数。128是小数据集下比较平衡的值;显存紧张就调成64,训练集很大可以试256。batch越大梯度越稳定,但占用内存也越大。
- MaxEpochs:遍历整个训练集的次数。MNIST这种规模,CNN跑10到15个epoch一般够了;如果你的数据量只有几千张,可能需要20到30个epoch并配早停。
- ValidationData和ValidationFrequency:这两个参数特别重要,它们能让你在训练过程中实时看到验证集准确率,及时发现过拟合。没有验证集的训练就是盲人摸象。
- Shuffle:设为every-epoch,让每个epoch数据顺序不同,能防止模型学到样本顺序带来的偶然规律。
训练结束后,trainNetwork返回一个net结构体,里面有整个网络的权重和层信息。到这一步,你的“基于神经网络的字符识别”模型就已经训练完毕了,接下来要做的是验证它到底好不好用。
4. 训练中的意外情况与排查记录:我的实测踩坑全过程
4.1 损失直接变成NaN
我首先要说的这个问题,几乎每个跑过深度学习的人都遇到过:训练一开始还正常,跑了没几个iteration,损失直接变成NaN,训练曲线像被一刀切断。看到这个现象,很多人的第一反应是换一个更大的网络,但方向完全反了。
NaN的根因大概率是梯度爆炸或数据里出现了非法数值。排查链路应该是:
- 先降低InitialLearnRate,比如从0.01降到0.001,重新训练。以我的经验,十次NaN里有七次是学习率过高导致的。
- 检查输入数据里是否有NaN或Inf。简单方法:
如果图像采集或预处理阶段出现了除零之类的错误,训练的损失也会变成NaN。assert(~any(isnan(imdsSample(:))), '输入数据存在NaN'); - 检查分类标签是否从0开始编号。classificationLayer要求标签是连续的整数,如果有类别没有任何样本,网络可能出现梯度异常。
4.2 训练集很好、验证集很差:过拟合问题
第二个高频问题是:训练准确率跑到99%,验证准确率却只有80%出头。这就是典型的过拟合。模型把训练样本“背”下来了,但对没见过的图片没有泛化能力。
我在一个小样本实验里跑出来的现象更明显:每个类别只有30张图,CNN训练到第10个epoch时训练集准确率100%,验证集却停在85%。这时候怎么处理?优先顺序是这样的:
- 先收集更多数据,哪怕每个类别多50张都有明显改善,这是最有效的一招。
- 在网络里加dropout层。Matlab里一行就能加:
放在全连接层之前。它的作用类似“抑制过拟合的随机开关”:训练时随机丢弃一部分神经元,强迫网络不要过度依赖某几个节点。dropoutLayer(0.5, 'Name', 'dropout1') - 数据增强。适当加入小角度旋转、轻微缩放、随机亮度变化,让模型见到更多样本形态。
- 换小一点的模型。如果数据本身就不多,把网络层数减少往往比堆更深的网络更好训。
4.3 自拍字符图片识别失败:预处理不一致
这个坑真的很隐蔽。一开始我用MNIST数据训练,测试集准确率也能到98%以上,但一到我拿手机拍的一串数字去识别,十张里有七八张是错的。当时我的第一反应是网络太差、不够深,后来一步步排查才发现问题出在三个预处理环节:
- 颜色相反:训练集是白字黑底,我的测试图片是黑字白底。网络看到的输入模式完全反了,准确率自然崩。
- 尺寸不一致:MNIST图片是28x28,我拍的照片经过拉伸变形后比例不对。
- 背景干扰:手机照片的背景有桌面纹路、阴影,没有做二值化就去resize,字符和背景糊在一起。
那次排查我写了一个小脚本,把测试图片经过每步预处理后的中间结果逐张显示出来,一眼就看出问题所在。我的建议是:无论什么时候做图像识别,都要把预处理流程的可视化放在重要位置——你不看到网络实际“吃”进去的图像内容,就永远不知道为什么效果不好。
4.4 训练时间过长与内存问题
还有个容易被忽视的现实问题:训练速度。在我的CPU笔记本上,一个简单的CNN跑10个epoch、约60000张训练图片,需要接近二十分钟;如果换成更深的网络,可能要一个多小时。这时你有几个选择:
- 检查是否真的在用GPU。Matlab的trainNetwork会自动启用可用GPU,但前提是Parallel Computing Toolbox已安装,并且你在trainingOptions里没有显式指定ExecutionEnvironment为'cpu'。
- 调小图片尺寸。比如从28x28改成24x24,训练时间会显著下降,精度损失通常很小。
- 减少epochs并用验证集早停,而不是傻跑满所有轮数。
- 如果数据量很大,可以先用一个子集做10分钟的快速实验,确认网络不炸、准确率趋势正确,再全量训练。
这些经验适用于大多数小型图像识别项目,字符识别只是其中典型的代表。
5. 部署与测试:模型搭好后还不能松气的那几步
5.1 测试集评估与混淆矩阵
训练完成不等于项目完成。你还需要在完全没参与训练的测试集上做最终评估,并且最好画一张混淆矩阵,把每个类别的错误情况看清楚:
YPred = classify(net, auTest); YTest = auTest.Labels; accuracy = sum(YPred == YTest) / numel(YTest); fprintf('测试集准确率: %.2f%%\n', accuracy * 100); figure; confusionchart(YTest, YPred);混淆矩阵的横轴是预测标签,纵轴是真实标签。对角线上数值大说明这个类别识别得好;非对角线上的亮点,就是模型容易混淆的地方。我遇到最多的是“8”和“3”互相认错、“0”和“6”偶尔混淆,这跟字符本身形态相近有很大关系。发现这类问题,最直接的办法是补充这类易混淆样本,或者对它们做专门的数据增强。
5.2 单张图片识别的完整函数
测试集评估是针对大量样本的统计结果,但实际部署时,你往往要处理的是“一张图,一次调用”的场景。我习惯封装一个识别函数,传入图片路径,返回识别标签和置信度:
function [label, score] = classifyCharImage(imgPath, net) im = imread(imgPath); im = preprocessChar(im); [label, score] = classify(net, im); % 显示原图与结果,方便直观验证 figure; imshow(imgPath); title(sprintf('识别结果: %s (置信度: %.3f)', char(label), max(score))); end这个函数特别适合在交付时给用户演示:拍一张照片,调用它,界面弹出字符和置信度。注意classify函数在输入单张图片时会返回标签和概率分数,分数数组的每个元素对应一个类别,取最大值所在位置就是最终判别的类别。
5.3 模型导出与落地
Matlab训练好的网络不能只在Matlab里玩,真实项目往往要求在Python服务、C++程序或者嵌入式设备上运行。这里有两个常见的导出方向:
| 导出方式 | 命令 | 适用场景 |
|---|---|---|
| 保存工作区模型 | save('charRecogNet.mat', 'net') | 继续在Matlab里识别、二次训练或演示 |
| ONNX模型导出 | exportONNXNetwork(net, 'charRecogNet.onnx') | 让其他语言框架加载推理模型 |
导出ONNX后,你可以把它放到支持ONNX的推理引擎里,走标准的模型转换和部署流程。这里我想强调一点:导出模型时,预处理流程和导出后加载图端的预处理必须保持完全一致,包括灰度化、二值化、缩放尺寸和极性方向。部署时最先出问题的往往就是这种“训练时做了某步、部署时忘了”的隐性不一致。
最后再分享一个我反复踩坑后总结出的习惯:做字符识别项目,永远先跑通一条最简单、可端到端验证的小流程,再逐步优化网络结构和参数。不要一开始就上超大的模型、十几个自选层、一堆花哨的数据增强——那样出了问题你都说不清是哪一环导致的。先把一个小CNN用默认参数训练起来,拿到合理的准确率,确认数据预处理没问题,再去调精度。这个顺序能让你节省至少一倍的时间,也更容易找到问题到底出在网络、数据还是代码上。