做手写数字识别,第一反应可能是上卷积神经网络,但说实话,在数据量不大、没有GPU的环境下,传统图像处理加机器学习算法的组合反而更实用,也更适合理解整个识别链路。这篇文章我直接用Matlab完成一个完整的手写数字识别流程,包括数字定位、5乘5分块分割、二值化处理、PCA主成分分析降维,以及决策树建模和交叉验证评估。
这套方案非常适合做课程设计、毕业设计,或者想快速搭建一个可解释性强的OCR原型。整个流程不依赖深度学习工具箱,只需要Matlab基础环境就能跑通。我尽量把每个环节的原理和代码都拆开讲,最后附上完整可运行的思路和常见坑,方便你直接复现。
1. 项目整体设计与方案选型
1.1 为什么在深度学习时代还用决策树
很多人会问,现在手写数字识别不都是用LeNet、ResNet这类卷积网络吗,为什么还要用决策树加PCA这种老思路?
我个人的观点是:不同场景选不同工具。决策树方案最大的优势是可解释性极强,每一步分类依据都能回溯,对理解特征工程和模型原理非常有帮助。而且决策树训练快、不需要大量数据、不需要GPU,在一台普通笔记本上几秒钟就能完成训练和评估。
对比一下两种方案的差异:
| 方案 | 数据需求 | 训练速度 | 可解释性 | 硬件要求 |
|---|---|---|---|---|
| CNN | 数万级别 | 慢(分钟到小时) | 弱(黑盒) | 需要GPU更佳 |
| 决策树+PCA | 数百到数千即可 | 极快(秒级) | 强(白盒) | 纯CPU即可 |
如果你的项目要求是理解识别原理、能讲清楚每个环节,或者做教学演示,传统方案明显更合适。另外,这套流程里的图像定位、分块、二值化、PCA降维这些步骤,在任何现代识别系统里都是通用前置技术,学会了不亏。
1.2 完整识别流程拆解
整个项目本质是一条标准图像分类流水线,从原始图片到最终输出数字标签,中间有五个关键环节:
- 图像预处理与二值化:把灰度图变成黑白图,抑制背景干扰。
- 数字区域定位:从整张图中找到数字所在的区域,裁掉多余背景。
- 5乘5分块分割:把定位后的数字图缩放成统一尺寸,再切成25个小格子。
- PCA特征降维:把25维的子块特征压缩到更低维度,同时保留大部分信息。
- 决策树建模与交叉验证:用降维后的特征训练分类器,评估泛化精度。
听起来步骤多,但实际串联起来就是几段Matlab脚本的事。每一步的目的都很明确,下面我逐个展开讲。
2. 图像预处理核心:二值化、定位与5乘5分割
2.1 二值化:为什么不能让灰度图直接入场
手写数字图片在拍摄或扫描过程中,背景往往不是纯白色,纸张纹理、阴影、墨迹深浅都会干扰识别。如果直接用灰度图提取特征,光照不均会造成同一数字的特征差异很大。
二值化的作用是把每个像素从0到255的灰度值归为两类:0代表背景,1代表前景文字。这样相当于把图像变成一张“黑白简笔画”,数字结构保留下来了,但冗余细节被删掉了。
Matlab里最简单的方法是设定一个全局阈值:
imgGray = rgb2gray(img); % 如果读进来是彩图先转灰度 bw = imgGray > threshold; % 高于阈值为白,低于为黑但实际项目中,阈值不能拍脑袋定。比如用手机拍的作业纸,环境亮度不同,同一支笔写出来的字灰度分布差别很大。固定阈值常常会出现整张图全黑或全白的情况。
推荐的做法是用Otsu方法自动计算阈值,Matlab里一句话搞定:
level = graythresh(imgGray); % Otsu全局阈值 bw = imbinarize(imgGray, level);做的时候还要根据前景背景颜色决定是否取反,保证数字区域是1(白色),背景是0(黑色)。如果数字是深色、背景是浅色,需要取反:
bw = ~bw; % 确保数字为前景实操中我见过不少同学二值化后忘记取反,导致后边定位找的全是背景区域,整个流程直接报废,所以这一步建议可视化检查一下。
2.2 数字定位:先用连通域锁目标,再裁边界
定位要解决的是“数字在图像哪个位置”的问题。最实用的是连通域分析法。二值化之后,图像中目标文字通常形成若干个连通的白色区域,我们只需要找到面积最大的那个区域,或合并距离较近的区域,就能锁定手写数字的大致范围。
cc = bwconncomp(bw); % 查找连通域 stats = regionprops(cc, 'BoundingBox', 'Area'); [~, idx] = max([stats.Area]); % 找到最大连通域 bbox = stats(idx).BoundingBox; % 外接矩形拿到外接矩形后,再做一步扩展裁切,保证别把数字边缘切掉:
x1 = max(floor(bbox(1)) - pad, 1); y1 = max(floor(bbox(2)) - pad, 1); x2 = min(ceil(bbox(1) + bbox(3)) + pad, size(bw, 2)); y2 = min(ceil(bbox(2) + bbox(4)) + pad, size(bw, 1)); digitROI = bw(y1:y2, x1:x2);这一步我踩过的坑是:当图片中有多个数字或噪声点的时候,面积最大不一定是要找的目标。比如纸张边缘的阴影在二值化后也可能形成大块连通区域。后来我在项目中加入了形态学开运算,先去掉细小的噪声点再做连通域分析,效果稳定很多:
bw = imopen(bw, strel('disk', 2)); % 去除细小噪点2.3 5乘5分割:为什么一定要切块,而不是直接缩放
定位并裁出数字区域后,不同图片里的数字大小不一样、笔画粗细不一样。这时候就涉及特征统一的问题。
一种直接的思路是把整张数字图缩放成固定尺寸,比如28乘28像素,直接把784个像素灰度值作为特征。这样当然可以,但维度太高,决策树容易过拟合,而且像素级特征对笔画偏移非常敏感,训练时稍微一歪就判错。
5乘5分块分割的核心思想是:把归一化后的数字图均匀划分成5行5列共25个子区域,再对每个子区域提取一个能代表局部像素分布的统计值,比如前景像素占比。这样原始图像的局部空间信息被压缩成25维向量,特征维度低,同时带有一定的抗偏移能力。
具体操作如下:
- 将数字ROI缩放为统一尺寸,我用的是50乘50像素。
- 把50乘50图像切成5乘5的网格,每个子块大小为10乘10像素。
- 计算每个子块中前景像素的比例,作为该子块的特征值。
- 把所有子块的特征拼成1行25列的向量。
imgResized = imresize(digitROI, [50, 50]); blockSize = 10; features = zeros(1, 25); for row = 1:5 for col = 1:5 r1 = (row-1)*blockSize + 1; r2 = row*blockSize; c1 = (col-1)*blockSize + 1; c2 = col*blockSize; block = imgResized(r1:r2, c1:c2); features((row-1)*5 + col) = sum(block(:)) / numel(block); end end你说为什么是5乘5而不是3乘3或10乘10?
这块我一开始也犹豫过。说实话,这个参数没有绝对标准,取决于图像复杂度和数据量。5乘5算是一个比较均衡的设定:每个子块10乘10像素,既保持了左上、右上、下弯、竖线这些局部结构,又不会因为分块太细导致维度爆炸和过拟合。如果你想追求更高精度,可以试试7乘7的分块,但需要更多训练样本。
我还额外试过另一种改进:在每个子块中同时提取均值、方差和最大像素值作为特征,这样每个数字得到75维的特征向量。效果有一点提升,但是决策树的训练时间明显变长,且提升幅度有限。最终以效果和效率的平衡来看,单独使用前景像素比例已经足够。
2.4 完整预处理函数参考
在实际工程中,我会把上述流程封装成一个函数,方便对多张图片批量处理:
function feat = preprocessDigitImage(imgPath) img = imread(imgPath); if size(img, 3) == 3 img = rgb2gray(img); end level = graythresh(img); bw = imbinarize(img, level); bw = ~bw; bw = imopen(bw, strel('disk', 2)); stats = regionprops(bwconncomp(bw), 'Area', 'BoundingBox'); [~, idx] = max([stats.Area]); bbox = stats(idx).BoundingBox; pad = 3; x1 = max(floor(bbox(1)) - pad, 1); y1 = max(floor(bbox(2)) - pad, 1); x2 = min(ceil(bbox(1) + bbox(3)) + pad, size(bw, 2)); y2 = min(ceil(bbox(2) + bbox(4)) + pad, size(bw, 1)); roi = bw(y1:y2, x1:x2); resized = imresize(roi, [50, 50]); feat = zeros(1, 25); idx = 1; for i = 1:5 for j = 1:5 block = resized((i-1)*10+1:i*10, (j-1)*10+1:j*10); feat(idx) = sum(block(:)) / 100; idx = idx + 1; end end end3. PCA主成分分析降维:不是玄学,是线性代数的基本功
3.1 为什么要降维,决策树不是不怕高维吗
有一部分同学可能会认为,决策树每次切分只选一个特征,高维数据好像也能处理。这个理解不够准确。
决策树单次分裂确实只选择一个特征,但它的分裂思想是“贪心地从所有特征里选最优”,在特征维度较高的时候,大量无关或冗余特征会干扰最佳分裂点的选择,训练出来的树层面更复杂,极容易过拟合。25维不算高,但其中包含大量相关性强的特征。比如数字“1”让左上和左下的格子同时产生高响应,这种特征具有天然相关性,全量喂进去其实给模型引入了噪声。
PCA做的事就是把原始的25维特征,通过线性变换映射到新的正交坐标系中,并且让新坐标系中前几个轴(即主成分)能解释原始数据的大部分方差。随后我们只保留前K个主成分,丢弃后面的维度。
这样做有三个明显的好处:
- 消除特征间的多重相关性,让决策树的特征选择更稳定。
- 降低维度之后,训练时间缩短、树结构更简单,泛化能力更强。
- 通过累计方差贡献率能直观判断特征压缩的信息损失。
3.2 PCA的数学本质,用一个例子说明白
我们假设所有图片的25维特征都存储在矩阵X里。每一列是一个“子块位置”,每一行是一张图。PCA目标就是找到一组新的基向量,把样本点投影到新基上,让投影后样本在每一个基向量方向上的方差最大化。
这里有一个关键点:PCA前一定要对每一维特征做标准化,即减去均值并除以标准差。如果不做标准化,前景像素占比本来就比较小、方差大的子块会主导主成分方向,维度之间失去可比性,结果会偏向大数值特征。
Matlab实现PCA非常简洁:
[coeff, score, ~, ~, explained] = pca(featureMatrix);其中:
- coeff是主成分系数矩阵,每一列是一个主成分方向。
- score是样本在主成分空间中的坐标,也就是降维之后的特征表示。
- explained是每个主成分解释的方差百分比,可以用来判断保留几个主成分。
举个例子:如果计算得到前5个主成分解释了总方差的87%,前8个解释了95%,那么保留8个主成分通常能覆盖足够多的原始信息。不要强行保留太少维度,不然数字“0”和“6”这种只在某个小区域有差异的情况会被抹掉信息。
3.3 训练集与测试集必须共享同一投影矩阵
这一步是新手最容易犯的错误,我必须专门提醒:PCA的投影矩阵只能从训练集上计算得到,测试集必须沿用训练集的coeff进行投影,而不是重新对测试集单独做PCA。
如果对测试集单独做PCA,得到的投影方向是测试集自己的最优方向,和训练集得到的方向完全不一致,等于是“各说各话”,训练出的模型在测试集上的评估结果毫无意义。
正确的做法是:
% 训练集 [coeff, scoreTrain, ~, ~, explained] = pca(XTrain); % 测试集直接用同一个 coeff scoreTest = XTest * coeff;如果你用Matlab自带的函数做交叉验证,比如fitctree配合crossval,可以先把所有样本放在一起做PCA,再划分训练集和测试集。但我更推荐的是在交叉验证的每一折内部重新执行PCA,这样评估结果更诚实。不过要说明的是,这类操作会显著拖长计算时间,在数据量不大时可以这么较真,数据量大点就直接全局PCA了。
关于保留多少维,我给出一个可直接参考的经验值:
| 分块方案 | 原始维度 | 建议保留主成分数 | 累计方差贡献率 |
|---|---|---|---|
| 5x5 | 25 | 8~10 | 约90%-95% |
| 7x7 | 49 | 12~15 | 约90%-95% |
拿5乘5分块来说,25维降到8-10维,压缩率超过60%,但识别精度基本不丢失,这就是PCA的实用价值。
4. 决策树建模与交叉验证实操
4.1 决策树为什么适合这种小型多分类任务
决策树分类器的核心思想是:通过一系列“if-then”规则把样本空间递归划分成不同类别区域。每次划分都会从候选中选择一个特征,并确定一个切分阈值,使得划分后子节点的“纯度”提升最大。Matlab默认采用CART算法,分裂标准是分类错误率或基尼指数。
决策树对这个项目的适配性很好,原因有三个:
- 样本量通常不大,决策树几秒就能完成训练。
- 特征经过PCA降维和分块提取后,数值范围相近,分裂点清晰稳定。
- 训练出的树可以通过view函数可视化,直观看到模型是依赖哪些区域进行判断的。
不过决策树有个天然弱点:单棵树方差较大,对训练数据敏感,样本稍微变化可能树结构就完全变了。这也意味着我们要重点观察交叉验证精度,看是否存在过拟合倾向。
4.2 训练与交叉验证的Matlab代码
假设已经把全部样本的特征矩阵存放于allFeatures中,标签存放于allLabels中。从特征矩阵中随机划分80%训练、20%测试,常规做法如下:
rng(42); % 固定随机种子,确保实验可复现 cv = cvpartition(allLabels, 'HoldOut', 0.2); trainIdx = cv.training; testIdx = cv.test; XTrain = allFeatures(trainIdx, :); YTrain = allLabels(trainIdx, :); XTest = allFeatures(testIdx, :); YTest = allLabels(testIdx, :); % 决策树建模 treeModel = fitctree(XTrain, YTrain, ... 'MaxNumSplits', 50, ... % 限制最大分裂数 'MinParentSize', 10, ... % 叶子节点最少样本数 'CrossVal', 'off'); % 预测 pred = predict(treeModel, XTest); accuracy = sum(pred == YTest) / numel(YTest) * 100;如果你要做严格的K折交叉验证,而非单次留出验证,推荐用cvpartition进行10折:
rng(42); cv10 = cvpartition(allLabels, 'KFold', 10); accuracies = zeros(cv10.NumTestSets, 1); for k = 1:cv10.NumTestSets trainIdx = cv10.training(k); testIdx = cv10.test(k); XTr = allFeatures(trainIdx, :); YTr = allLabels(trainIdx, :); XTe = allFeatures(testIdx, :); YTe = allLabels(testIdx, :); model = fitctree(XTr, YTr, 'MaxNumSplits', 40, 'MinParentSize', 10); pred = predict(model, XTe); accuracies(k) = sum(pred == YTe) / numel(YTe) * 100; end meanAcc = mean(accuracies); stdAcc = std(accuracies);在真实实验中,10折交叉验证的平均精度大概稳定在85%~92%之间,具体会受图片质量影响。例如训练数据是某种字体扫描出来的,测试数据是手写体,精度会掉到80%以下。这很常见,原因是手写风格差异太大,单纯的像素统计特征只能捕获粗粒度结构。
4.3 决策树剪枝:控制过拟合的关键
如果不设置任何限制,决策树会一直分裂到每个叶子节点只包含一个样本为止。这在训练集上效果极好,甚至能达到100%准确率,但测试集上立刻原形毕露。
控制过拟合有两种手段,一种是前置剪枝,在fitctree里直接限制树深度和叶子最小样本数;另一种是后置剪枝,先生成一棵完整树,再调用prune方法裁剪冗余节点。
我以前长期用前置剪枝,简单直接,推荐关注三个参数:
model = fitctree(XTrain, YTrain, ... 'MaxNumSplits', 20, ... % 最多分裂20次 'MinLeafSize', 5, ... % 每个叶子最少5个样本 'MinParentSize', 10, ... % 父节点最少10个样本才会继续分裂 );其中MinLeafSize是最重要的参数,它直接规定了单类样本不能少于5个才允许叶子节点出现。调大到15-20,树会更简单,交叉验证精度可能出现先升后降的趋势,可以通过实验对比。
4.4 画出混淆矩阵,比只看准确率有信息量得多
评估分类模型不能只看整体精度,我建议至少画一次混淆矩阵,看到底哪些数字容易混。
cm = confusionmat(YTest, pred); heatmap(cm, 'Colormap', parula);真实测试中,“0”和“6”的混淆出现在子块下侧圆弧特征接近的情况,“7”和“9”也偶尔混。发现这些混淆模式后,如果精度瓶颈集中在某几组数字,可以考虑针对性地增加对应样本,或者在分块特征中单独提取一些抗混淆的判别特征。
5. 主流程串联:一份可直接参考的Matlab脚本结构
5.1 整体工程文件目录建议
|-- data/ | |-- train/ | | |-- 0/ | | |-- 1/ | | |-- ... | | `-- 9/ | `-- test/ | |-- 0/ | |-- 1/ | |-- ... | `-- 9/ |-- preprocessDigitImage.m |-- trainDecisionTree.m `-- main.m用文件夹名作为标签,好处是不需要额外维护标签文件,遍历目录时就自动获得标签了。这也是很多传统图像分类项目的通用做法。
5.2 主脚本逻辑
%% 1. 读取训练集图片并提取特征 trainRoot = 'data/train'; categories = dir(trainRoot); categories = categories([categories.isdir]); categories = categories(~ismember({categories.name}, {'.', '..'})); allFeatures = []; allLabels = []; for i = 1:length(categories) label = str2double(categories(i).name); imgFolder = fullfile(trainRoot, categories(i).name); imgFiles = dir(fullfile(imgFolder, '*.png')); imgFiles = [imgFiles; dir(fullfile(imgFolder, '*.jpg'))]; for j = 1:length(imgFiles) imgPath = fullfile(imgFolder, imgFiles(j).name); feat = preprocessDigitImage(imgPath); % 若不同文件夹下图片格式不统一,用dir的覆盖方式还是不够稳 allFeatures = [allFeatures; feat]; allLabels = [allLabels; label]; end end读取全部样本后,再执行PCA + K折交叉验证 + 混淆矩阵输出:
%% 2. PCA降维 [coeff, scoreTrain, ~, ~, explained] = pca(allFeatures); % 选择保留 90% 方差所需的主成分数 cumVar = cumsum(explained); k = find(cumVar >= 90, 1); fprintf('保留 %d 个主成分,累计方差贡献率 %.2f%%\n', k, cumVar(k)); reducedFeatures = scoreTrain(:, 1:k);5.3 预测新图片
在验证完整体流程后,真正用于新图片预测时,要记得把PCA步骤再次跑一遍:
function label = predictDigit(model, coeff, k, imgPath) feat = preprocessDigitImage(imgPath); featReduced = feat * coeff(:, 1:k); % 使用训练阶段的投影矩阵 label = predict(model, featReduced); end这里最值得记住的就是featReduced的计算方式,不是对feat单独做PCA,而是直接用训练阶段学到的coeff乘以新样本的特征向量。
6. 常见问题与排错实录
我在实际调试这套流程时,遇到过不少问题,挑几个典型的列在这里做个速查,方便你少走弯路。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 二值化后图像整体全黑或全白 | 彩色图转灰度后未正确取反,或Otsu受强噪声干扰 | 检查bw中数字是否为白色前景,必要时bw=~bw |
| 数字定位总是框到背景噪声 | 图像边缘存在阴影或小墨点 | 先做imopen形态学去噪,再限制连通域面积阈值 |
| 5乘5分块特征很多为0或全为1 | 二值化后前景背景反转,ROI裁切到了空白区域 | 可视化每一张预处理后的ROI,检查前景占比是否正常 |
| 决策树训练精度100%但交叉验证只有70% | 严重过拟合,树没做剪枝限制 | 增大MinLeafSize,设置MaxNumSplits,必要时降低特征维度 |
| 训练集精度90%但测试集只有60% | 训练集和测试集图像分布差异太大 | 统一图像的预处理和归一化流程;检查有无反色图片被当作样本 |
6.1 多数字图片怎么处理
如果一张图里有多个手写数字需要识别,比如一行5个数字,最稳妥的思路是先用投影法或连通域分列切出每个数字的独立区域,再逐个调用preprocessDigitImage进行识别。这里要额外注意字符重叠或连笔造成的切分失败。
我用过的切分方案是先做垂直方向投影,统计每一列的前景像素数量,某列完全没有前景像素即视为字符间隔,顺着这个间隔把数字切成独立子图。如果两个数字粘连很严重,投影法会失效,此时通常需要引入更精细的分割算法,比如滴水算法或基于深度学习的检测模型。
6.2 数据增强是否有必要
由于决策树方案可解释性强,但抗扰动能力弱于CNN,适当的训练数据增强能直接提升泛化精度。最简单的做法是对原始图像做随机小幅平移、旋转或缩放:
augmented = imtranslate(roi, [randi([-2, 2]), randi([-2, 2])]); augmented = imrotate(augmented, randi([-5, 5]), 'bilinear', 'crop');平移2个像素、旋转5度以内、缩放0.9到1.1倍的组合,对MNIST这类居中数字效果不错。需要注意的是增强后的图片要裁剪回原始尺寸,统一归一化后再分块,避免引入空白边框。
6.3 主成分数选择的经验法则
如果你的数据里数字风格比较单一,前5个主成分可能就贡献了90%以上方差,降到5维已经能取得较好的精度。但遇到手写风格差异大的数据,方差会比较分散,可能要保留10个左右的成分。
不要死记数字,直接用累计贡献率曲线来决策:
figure; plot(cumsum(explained), 'o-'); xlabel('主成分个数'); ylabel('累计方差贡献率(%)'); grid on;观察曲线的肘部位置,通常取曲线明显变平缓的点作为保留数量。这个方法比拍脑袋选一个数可靠得多。
7. 进一步优化与个人经验小结
如果觉得当前精度不够,有几个方向可以继续打磨。
第一个方向是特征维度的扩展。比如原始5乘5分块只用前景占比,可以同时统计每个子块中前景像素的质心坐标、笔画密度、水平或垂直方向的连续像素长度等,这样每个子块不再是一个标量,而是一个小向量,特征表达能力更强。
第二个方向是换用更先进的基学习器。决策树作为单一模型上限有限,但当你把Bagging或随机森林引入后,精度一般能再提升3到6个百分点,实现也简单:
bagModel = TreeBagger(200, reducedFeatures, allLabels, ... 'Method', 'classification', 'MinLeafSize', 5);我个人在实际操作中的体会是:这套预处理和特征工程流程才是核心资产,模型反而是最简单的部分。你以后从决策树换到随机森林、甚至浅层神经网络,前面那些定位、分割、二值化、PCA的代码都能无缝复用。
最后再分享一个小技巧:在正式训练前,务必把所有经过预处理的ROI图像单独保存在一个debug文件夹里,用subplot拼成网格图快速目检。比如你可以用montage函数一次性看几十张图。这一步虽然看起来不起眼,但它能帮你迅速发现加载错文件、图片反色、裁切错误这三类最隐蔽的问题。确认输入干干净净,再谈模型调参才有意义。