图像分类和图像检索,表面上是两套任务,实际做起来经常是同一套系统的上下半场。我最近在 MATLAB R2025a 里把“先分类、再找相似图”的完整链路搭通了,从数据整理、分类模型微调,到特征提取、相似度排序,最后跑出一个能随时加图片测试的 Demo。这篇文章就把当时的设计思路、关键参数、代码细节和踩坑过程全部写出来,适合那些刚把一个深度学习项目跑通、还想进一步做成完整产品原型的人参考。
很多朋友一上来就抱着某个网络结构反复调参,结果训练倒是收敛了,真到检索场景里一换图片就拉了胯。我这次一开始就把分类和检索当成两个互相配合的模块来设计:分类模块负责给未知图片打上语义标签,检索模块负责在同一个标签域内找视觉上更接近的样本。这样做的好处是,分类结果可以用来缩小检索范围,检索结果反过来能补充分类时不敢拍板的模糊样本,业务上构成一个自洽闭环。
1.1 在 MATLAB R2025a 里做这件事,优势在哪儿
先说结论:如果目标是快速搭一套可复现、可验证的图像处理原型,MATLAB 比很多人想象中要合适得多。
R2025a 里,深度学习工具箱、计算机视觉工具箱和并行计算工具箱的配合已经相当成熟。用imageDatastore管理几万张图片只需一两行代码,augmentedImageDatastore直接内置随机裁剪、翻转、颜色抖动这些常见增强,不用自己造轮子。更关键的是,训练过程中可以实时打开训练进度图,验证曲线、学习率变化、损失震荡一眼就能看到,这在调参初期比看一堆日志高效太多。
另外,这类系统的核心不是把网络结构写得多花哨,而是“数据管理 + 特征处理 + 结果可视化”这条链路的稳定性。MATLAB 的矩阵化原生接口,让特征矩阵的拼接、归一化、距离计算变得非常直接,甚至在命令行里跑pdist2就能完成一次完整检索验证。很多人担心工具箱之间数据格式不兼容,实际上 R2025a 里datastore、tall数组和深度学习接口的衔接已经平滑了很多。
1.2 系统整体流程与模块分工
整个系统我拆成了两条流水线。
第一条是分类流水线:原始图片文件夹 →imageDatastore按目录名自动打标 → 数据增强 → 微调预训练卷积网络 → 输出分类标签和置信度。控制台界面只需要输入一张图片路径,就能看到预测标签 top5以及每类置信度,方便人工确认到底是被哪一类“迷惑”了。
第二条是检索流水线:先对全库图片提取深度特征,统一保存成特征矩阵;查询时提取同一张图的特征,计算与库内所有特征的相似度,按分数倒序返回 Top-10 结果,配合montage直接把相似图片阵列展示出来。检索时可以单独用全库,也可以先指定某个分类标签再检索,后者速度更快,结果也更容易解释。
两条流水线共用同一个训练好的网络。这也是我认为“分类 + 检索”适合做成一个系统的根本原因:检索特征不必单独训练,直接把分类网络中间层输出拿来用,就能得到一个不差的视觉特征表示。
2. 分类模块:数据准备、网络微调与训练参数
分类模块是整个系统的基础,它的效果直接决定检索特征的语义质量。我在这轮实验里踩过的坑比较多,所以把从数据组织到训练验证的全过程都拆开讲一遍。
2.1 数据集怎么组织,预处理为什么不能只做“缩放”
MATLAB 的imageDatastore采用按文件夹名自动生成标签的机制,这一点非常省心。目录结构建议是:
dataset/ airplane/ bicycle/ bird/ ...每个文件夹里放对应类别的图片。imageDatastore读进来以后,标签自动等于文件夹名,不需要额外写 CSV 标签文件。
需要注意的一个坑:如果同一类图片是从网络爬来的,里面极可能有大量尺寸不一、带水印、甚至损坏的图片。直接丢进训练会浪费大量迭代次数。我当时先写了一个简单脚本,把所有数据统一转成 3 通道 RGB,尺寸短边至少 256 像素,损坏文件直接剔除。看似啰嗦,但这步能省掉后边一半的训练问题。
数据增强方面,R2025a 的augmentedImageDatastore是标准做法。我用的增强包括:随机水平翻转、随机平移 ±10 像素、随机缩放 1.0~1.1、轻微颜色抖动。有一点要特别提醒:像“车牌识别”这类对方向敏感的垂直对象,最好不要开水平翻转;像“花卉类别”这种依赖颜色细节的任务,颜色抖动幅度也不能过大,否则模型会误把颜色当成唯一判别依据。
2.2 微调网络时,改哪几层才算改对了
我不是从头训练网络,而是直接加载一个在超大规模自然图像上预训练好的卷积网络,常见选择是 ResNet-18 或 GoogLeNet。R2025a 里直接用resnet18函数就能拿到预训练模型和配套的层结构。
微调要改的其实只有最后面的几层:全连接层要改成自己的类别数,激活函数、池化层通常保留原样,最后的分类层也要重建。直接用replaceLayer就能替换:
lgraph = layerGraph(net); lgraph = replaceLayer(lgraph, 'fc1000', ... fullyConnectedLayer(numClasses, 'Name', 'fc_custom')); lgraph = replaceLayer(lgraph, 'ClassificationLayer_predictions', ... classificationLayer('Name', 'class_output'));这里有个关键点:不同版本网络层名可能略有差异,所以替换前我习惯先打印lgraph.Layers看一眼真实的层名,再写替换代码。R2025a 里如果层名对不上,会直接抛异常,这个报错反而能帮你确认名字。
关于是否冻结浅层:如果数据量不大,我建议把除最后几层之外的参数全部冻结,也就是只训练分类头和最后一组卷积块。数据量很充足时,再放开更多层的训练。这个选择直接关系训练速度和过拟合风险。我的做法是先用freezeWeights冻结前 70% 的层,跑通全流程后再逐步解冻。
2.3 训练参数怎么定,验证时看什么
训练选项我用的是trainingOptions('adam'),初始学习率从 1e-4 开始。如果直接沿用别人常用的 1e-3,经常会在模型加载预训练权重的情况下发生损失震荡,因为预训练模型本身的特征已经比较成熟,大步长反而容易把好权重冲掉。
完整训练配置大致是这样:
options = trainingOptions('adam', ... 'InitialLearnRate', 1e-4, ... 'MaxEpochs', 25, ... 'MiniBatchSize', 32, ... 'ValidationData', augVal, ... 'ValidationFrequency', 20, ... 'Shuffle', 'every-epoch', ... 'Plots', 'training-progress', ... 'Verbose', true);这里ValidationFrequency不能设得太大,不然每个 epoch 只能看到一两次验证结果,难以及时发现过拟合。训练完成后,我习惯用classify跑一遍验证集,再画混淆矩阵。分类模型看起来不错时,并不是终点,还要重点关注那些被混淆的类别对,它们很可能也是检索阶段的难点。
3. 检索模块:特征提取、降维与相似度计算
分类网络跑通以后,接下来的工作就是把它当成一个“特征提取器”,而不是一个简单的分类器。检索模块的好坏,很多时候不取决于网络有多深,而取决于特征怎么提、怎么处理、怎么比。
3.1 用哪一层的特征最合适
这是整个检索模块首先要回答的问题。早期卷积层特征偏向颜色、边缘、纹理,分辨率高但语义弱;最后一层全连接层特征语义强但对空间细节不敏感;中间池化层往往是个比较好的折中。R2025a 里activations函数可以直接提取任意层的输出。
我最后选择的是最后一个残差块之后的全局平均池化输出,在 ResNet-18 里对应pool5层。这个层输出的特征维度是 512 维,既有足够的语义信息,又不至于像全连接层那样维度太高、计算量大。如果类别粒度更细,比如不同款式的鞋,可以尝试把res4b_relu这类中间层特征也拼接进来,维度增加但检索精度会提升。
我用一个小实验验证了这一点:同一批查询图片分别用倒数第一层全连接和pool5特征做检索,pool5在“背景相似的错误召回”上明显更少,说明具体位置信息比抽象类别信息对“相似感”更重要。
3.2 特征归一化与降维
很多教程会忽略特征预处理这一步,但这恰恰是检索效果差异最大的地方。原始特征向量的各个维度量级差异很大,如果不做 L2 归一化,距离计算会被少数大数值维度主导,检索结果往往跑偏。我提取完特征后统一做一次 L2 normalize:
features = normalize(features, 2, 'norm');之后再用 PCA 降维。这里不是为了省内存,而是为了去掉冗余信息。R2025a 中直接pca(features, 'NumComponents', 128)就能得到降维后的映射矩阵。我保留 128 维时,检索精度几乎没有损失,但检索速度提升明显。
有一个经验:PCA 应该用全库特征去拟合,而不是用训练集拟合后套到全库,否则会把测试分布搞偏。降维之后再做一次 L2 归一化,注意顺序不能反。
3.3 距离度量与检索排序
相似度计算我推荐余弦相似度。欧氏距离对向量长度敏感,而 CNN 特征经过 ReLU 后会有大量非负值,向量长度的差异并不一定对应视觉差异。使用余弦相似度后,特征方向一致的两个样本会被判为相似,更符合“视觉风格相近”的直觉。
求排名时用pdist2就能搞定:
D = pdist2(featuresQuery, featuresLibrary, 'cosine'); [~, idx] = sort(D, 'ascend'); topK = idx(1:10);但如果查询图片本身就在特征库里,第一名的距离一定是 0,需要把自身排除。这个细节很容易漏,我当时第一次跑检索时返回的第一张图永远是查询图本身,差点误判为“检索效果极好”。
另外,检索时还可以利用分类模块输出的标签做限制。比如先分类成“鸟”,再只在“鸟”这个类别的特征子集里做检索,既快又准。这比在全库上暴力距离计算更符合实际业务场景。
4. 可复现的实操过程:核心代码与运行流程
这一节我把能直接跑通全流程的代码骨架放出来,按顺序执行即可。具体数据集路径和类别数需要自己替换。
4.1 环境准备与目录约定
本机环境是:MATLAB R2025a,安装了 Deep Learning Toolbox、Computer Vision Toolbox、Parallel Computing Toolbox。GPU 不是必须,但建议至少有一块显存 8GB 以上的显卡,训练迭代会快很多。
目录约定如下:
project/ data/ class01/ ... 每类图片 output/ net_trained.mat features.mat pcaModel.mat scripts/ trainModel.m extractFeatures.m searchImage.m图片统一整理在data下,输出文件统一放到output,脚本之间用 clear 变量避免内存占用互相干扰。
4.2 分类训练核心代码
trainModel.m的思路是:读取数据 → 切分验证集 → 增强 → 替换网络输出层 → 训练 → 保存模型。
% 1. 读取数据 imds = imageDatastore('data', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames'); % 2. 按比例切分 [imdsTrain, imdsVal] = splitEachLabel(imds, 0.8, 0.2, 'randomized'); % 3. 增强 augTrain = augmentedImageDatastore([224 224 3], imdsTrain, ... 'RandXTranslation', [-10 10], ... 'RandYTranslation', [-10 10], ... 'RandXScale', [1 1.1], ... 'RandXReflection', true); augVal = augmentedImageDatastore([224 224 3], imdsVal, ... 'ColorPreprocessing', 'none'); % 4. 加载预训练模型并替换最后层 net = resnet18; lgraph = layerGraph(net); numClasses = numel(categories(imdsTrain.Labels)); lgraph = replaceLayer(lgraph, 'fc1000', ... fullyConnectedLayer(numClasses, 'Name', 'fc_custom')); lgraph = replaceLayer(lgraph, 'ClassificationLayer_predictions', ... classificationLayer('Name', 'class_output')); % 5. 训练选项 options = trainingOptions('adam', ... 'InitialLearnRate', 1e-4, ... 'MaxEpochs', 25, ... 'MiniBatchSize', 32, ... 'ValidationData', augVal, ... 'ValidationFrequency', 20, ... 'Plots', 'training-progress', ... 'Verbose', true); % 6. 训练并保存 netTrained = trainNetwork(augTrain, lgraph, options); save('output/net_trained.mat', 'netTrained');跑完之后,在命令行里简单验证:
[YPred, scores] = classify(netTrained, augVal); acc = mean(YPred == imdsVal.Labels);这个准确率只能说明验证集分布内的表现。我自己后来的教训是,单独把每个类别照片拍摄角度的子集拿出来测一轮,结果比全局准确率低了十几个百分点,说明拍摄条件变化仍是真实场景中的大麻烦。
4.3 特征库构建与检索代码
extractFeatures.m负责把全库图片提取成特征矩阵并保存。第一次运行会慢一些,所以我会把特征矩阵存成 mat 文件,后续检索不再重复提取。
% 构建全库 datastore imdsAll = imageDatastore('data', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames'); allLabels = imdsAll.Labels; % 统一尺寸增强 augAll = augmentedImageDatastore([224 224 3], imdsAll, ... 'ColorPreprocessing', 'none'); % 提取 pool5 特征 layerName = 'pool5'; featuresAll = activations(netTrained, augAll, layerName, ... 'OutputAs', 'rows'); % L2 归一化 featuresAll = normalize(featuresAll, 2, 'norm'); % PCA 降维并保存模型 [coeff, score, ~, ~, explained] = pca(featuresAll, ... 'NumComponents', 128); featuresReduced = score; save('output/features.mat', 'featuresReduced', 'allLabels', 'coeff');searchImage.m的核心逻辑是:读查询图片 → 用同一网络提取特征 → 应用保存的 PCA 系数 → 归一化 → 与全库特征算余弦距离 → 排序返回。
function [topLabels, topIdx] = searchImage(queryImgPath, netTrained, featuresReduced, allLabels, coeff) img = imread(queryImgPath); img = imresize(img, [224 224]); layerName = 'pool5'; feat = activations(netTrained, img, layerName, 'OutputAs', 'rows'); feat = normalize(feat, 2, 'norm'); feat = feat * coeff; % 应用 PCA feat = normalize(feat, 2, 'norm'); D = pdist2(feat, featuresReduced, 'cosine'); [~, idx] = sort(D, 'ascend'); topK = idx(1:10); topIdx = topK; topLabels = allLabels(topK); end注意,feat * coeff这一步用的是训练全库特征时得到的 PCA 系数。查询特征必须经过同一套映射,不能重新再拟合一次 PCA,否则特征空间根本不对齐。
5. 常见问题与排查技巧实录
这部分是我在这套系统上实际踩过的坑。每个问题都不是凭空想象,我把现象和解决过程都记录下来,大概率你能直接命中其中的一两个。
5.1 训练阶段:损失下降慢、准确率上不去
最常遇到的情况是“训练一开始 acc 就在 20% 左右卡住”,多半是学习率太大导致预训练权重被破坏,或者替换层没设置正确。先把InitialLearnRate降到 1e-4 至 1e-5,再把网络浅层冻结,基本能解决。
另外,如果数据类别严重不均衡,某些类只有 30 张,另一些类有 3000 张,splitEachLabel默认会按比例切分,但训练时模型仍会被大类别主导。我建议对图像数量少的类别做多倍复制,或者直接使用加权分类层。在 R2025a 里可以用classWeights自定义一个加权分类层,这一步能显著改善少样本类别的召回。
5.2 检索阶段:返回结果“看起来像”却语义不对
检索返回的 Top-K 图像背景相似但主体类别不同,这个问题很大概率出在“特征层选得太浅”或者“特征没有做归一化”。
浅层特征对颜色、纹理过于敏感,背景相似很容易被误判。把特征层换成pool5或更靠近语义的层,通常能缓解。另一个问题是如果查询图和库图在分辨率、光照上差异巨大,imresize([224 224])会丢失大量细节。解决办法是尽量保证所有图在进入特征提取前先做相同的预处理,至少统一尺寸。
还有一类情况是 PCA 降维后检索效果反而变差。这说明保留维数太少,主成分把判别性信息也丢掉了。建议画一下explained曲线,保留累计解释率到 95% 左右的维数,而不是拍脑袋设成 128 维。
5.3 R2025a 版本相关的坑
R2025a 的深度学习接口相比旧版本有一些调整。比如augmentedImageDatastore仍然推荐使用,但如果你在旧教程里看到imagedatastore相关函数的大写风格,直接照抄经常会因为参数名不一致报错。新接口统一为小写开头的函数风格。
还有一点,activations函数提取特征时,OutputAs这个参数名很容易记错。如果你传'OutputAs','columns',返回的是通道维在列的格式,直接拿去pdist2前要确认维度方向。我在第一次检索时就是因为这个维度搞反了,相似度计算全成了垃圾值,排查了很久。
最后,如果机器内存不高又开了并行训练池,R2025a 有时会把数据加载到 worker 上,导致内存瞬时暴涨。可以把trainingOptions里的'ExecutionEnvironment','cpu'或调小MiniBatchSize临时验证,确认代码逻辑没有问题后再开 GPU。
6. 实测体会与后续可扩展的方向
这套系统做完,我最大的体会是:分类和检索不是两个孤立的研究方向,它们是同一个特征表示在不同任务上的两种应用。分类任务逼着网络学到“类别判别力”,检索任务逼着特征保留“细粒度相似性”。所以训练分类网络时,不要只看准确率,还要多看看中间层的可视化特征图,这会直接影响后面检索效果。
6.1 最值得认真对待的一个环节
如果让我只保留一条经验,那就是“查询图片和库图片的特征提取路径必须完全一致”。很多人把训练阶段的增强开得很大,到了检索阶段又换了一套完全不同的预处理,结果两张同样的图片提取出来的特征差距都很大。我在代码里强制所有图片都走imresize → 同一网络 → 同一层 → 同一 PCA → 同一归一化的管线,检索稳定性立刻上了一个台阶。
另外,检索系统的评价指标也很容易踩坑。准确率、召回率、mAP 这些指标看着简单,但如果你用的查询集和库集有重叠,结果就会虚高。我当时专门把查询集与库集做了去重,保证每一张查询图都不在库中,才算得到真实可对外汇报的数字。
6.2 想继续做深可以往哪些方向走
现在这套系统已经能完成“输入图片 → 判断类别 → 找到相似样本”的基本闭环。如果继续扩展,我比较推荐三个方向。
第一个是更细粒度的分类,比如把“鸟”的类别继续拆成不同物种,这会逼着特征层去关注嘴型、翅膀纹理这些局部细节,检索效果也会有质的提升。第二个是引入多尺度特征融合,把浅层细节特征和深层语义特征拼接起来,显著提升对遮挡、模糊样本的鲁棒性。第三个方向是把 MATLAB 训练好的网络导出成 ONNX 格式,部署到外部推理环境。R2025a 的exportONNXNetwork已经比较成熟,导出后可以接入移动端或者服务端,让原型真正变成产品的一部分。
对我来说,这套系统最值钱的不是最后那个漂亮的检索面板,而是过程中把数据、训练、特征、检索这四段流程完全打通的经验。如果你正在做一个类似的图像处理项目,希望这篇内容能帮你少走几段弯路。