☰
食物图像分类实战:从数据清洗到模型部署的完整指南
2026/10/18 18:20:33 网站建设 项目流程

上个月我接到一个模拟项目X的任务:给餐饮管理场景做一套食物图片自动分类能力。作为一个常年把MNIST、CIFAR跑得滚瓜烂熟的图像分类玩家,我一开始真没把这事儿当回事——换个数据集、换套标签文件,剩下的流程不都一样嘛。但真正把图片下载下来、扫了一遍之后,我的想法彻底变了:食物分类和那种"猫狗大战"级别的分类任务,完全不是一个难度层次。

这篇博文就围绕这次"深度学习实战——食物分类"项目的完整过程展开。我会把从数据清洗、预处理增强、模型选型、训练调参到评估部署的整套链路记录下来,包括那些只有亲手做过才会发现的坑。内容适合两类人看:一类是刚做完入门级分类任务、想挑战真实图像数据的同学,另一类是把图像分类跑通了但始终觉得"哪里不对劲"、不知道下一步该怎么深挖的开发者。读完你至少能搞清楚一件事:食物分类到底特殊在哪,以及一套真实可用的分类方案是怎么从零搭起来的。

1. 食物分类项目立项:先想清楚"分什么"比"怎么分"更重要

1.1 为什么食物比猫狗更适合当实战练手

很多人觉得图像分类入门就该拿猫狗数据集练手,我原来也这么想。但真实业务里,极少有人需要你区分一只猫和一只狗,因为这两个类别在ImageNet预训练模型里早就被分得明明白白了。食物分类则完全不同,它逼着你去处理三种真实存在的麻烦。

第一种麻烦是类间相似。麻婆豆腐和蚂蚁上树从俯拍视角看,都是"碎末加红色油光"的形态;水煮鱼和酸菜鱼在浓汤和辣椒覆盖下,不仔细看纹理几乎分不出彼此。第二种麻烦是类内差异大。同样一道宫保鸡丁,不同餐厅的装盘、配菜、酱汁颜色可以差出十万八千里,模型要学的是"这道菜的本质特征",而不是某家店的固定摆盘。第三种麻烦是背景和拍摄条件不可控。食堂餐盘、外卖盒、木桌、大理石台面,加上不同的光线和角度,我在数据里见过同一道菜从正上方俯拍和45度斜拍呈现出的完全不同的视觉分布。

这三种麻烦凑在一起,食物分类就成了一块非常好的"练肌肉"项目。它比纯学术数据集更接近落地场景,又不像工业缺陷检测那样需要特殊成像设备。分类准确率之外,你还得关心数据清洗、类别体系设计、误判代价权衡这些真正影响生产环境质量的环节。说实话,跑完这个项目再回头看CIFAR,你会觉得那些所谓的"难点"都像是在温室里养出来的。

1.2 数据集选择:公开基准还是自己爬

我这次用的是某公开的101类食物数据集,总共十万张图,每类一千张,官方已经把训练集和测试集按大约7比3的比例固定划分好了。选它的原因很直接:类别数足够多,不会像十类数据集那样容易产生"怎么调都过拟合"的错觉;每类样本量又足够支撑迁移学习的微调。还有一个好处是类别体系相对干净,大部分类名的边界是清楚的,比如"法式炸薯条"和"普通薯条"这种细分在真实业务里反而不常见。

如果你打算自建数据集,我得先泼一盆冷水:食物图片的采集成本远比你想象的高。公开数据集里的图片大多是用户上传或从食谱网站抓的,你可以直接拿来做模型能力验证,但如果在生产环境直接用它,会因为拍摄设备、光线、餐具风格不同导致明显的性能滑坡。我的做法是拿公开数据做训练和内部验证,再另外攒了一套只有三类食物的自采图做盲测。盲测结果才是我判断模型能不能真正上线的依据。

这里有一个我踩过的决策坑:一开始我贪图省事,直接把公开数据集的全部101类都塞进训练,结果发现模型在后厨场景里频繁把"章鱼烧"和"炸虾天妇罗"搞混。后来想明白了,餐饮管理场景根本不需要那么细的类别体系,多出的类别只会放大混淆矩阵里的噪声。正确的做法是先梳理业务需要的类别清单,再从公开数据里抽取对应的子集。换句话说,数据集的确定不是"有什么用什么",而是"业务要什么才留什么"。

2. 数据预处理与增强:食物模型最容易在此翻车

2.1 从图片路径到训练张量的标准链路

预处理链路在PyTorch里看起来就是几行配置的事,但每一个参数的选择都会实打实影响最终效果。我这次用的是ImageFolder结构,目录按类别组织,配合DataLoader直接加载。标准写法大致是这个样子:

from torchvision import transforms from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = ImageFolder(root='data/food/train', transform=train_transform) val_dataset = ImageFolder(root='data/food/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=8, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=8, pin_memory=True)

很多同学在MNIST上根本不需要考虑Normalize的数值,因为灰度图均值方差很稳定。但食物图像是RGB三通道,且颜色本身就是重要的判别特征,Normalize参数会直接影响模型对色调的敏感度。我用的是ImageNet统计值,原因很简单:预训练骨干网络是在ImageNet上训出来的,输入分布保持一致,迁移效果最稳。你也完全可以在训练集上重新统计均值方差,我试过一次,收益微乎其微,不值得多花时间。

2.2 食物场景下的增强组合:为什么RandomResizedCrop是主角

数据增强对自然图像分类几乎是标配,但不同任务对增强的敏感度差异很大。我的实测结论是:食物分类里,RandomResizedCrop是无条件的第一主角。原因在于同一道菜在不同图片里,主体占画面的比例浮动极大。有的图是特写,食材细节撑满整个画面;有的图是整桌俯拍,一道菜只占画面一小块。固定Resize加中心裁剪会把大量有效信息直接丢掉,而随机裁剪加缩放能在每个batch里给模型提供不同尺度的视角,逼着它同时学会看"整体菜形"和"局部食材纹理"。

我用的增强组合和理由值得单独列一下:

增强操作参数建议对食物分类的作用
RandomResizedCropscale=0.6~1.0模拟不同拍摄距离,让模型适应主体占比变化
RandomHorizontalFlipp=0.5增加构图多样性,几乎没有副作用
RandomRotation15度以内模拟手持拍摄时的轻微歪斜,角度太大容易裁出无意义背景
ColorJitterbrightness/contrast各0.2模拟不同光线环境,但幅度不宜过大,否则会让模型忽略颜色细节

还有个操作我这里特意没加:RandomErasing。它本来是很好的正则化手段,但食物分类里执行随机擦除时,有很大概率把肉片、菜叶这类判别性最强的区域直接抹掉,结果模型被迫去学"盘子的形状""桌布的纹理"这些不该学的特征。我在一轮实验里加了RandomErasing,验证集准确率掉了将近两个点,排查了半天才发现是它在捣乱。

2.3 标签噪声与脏数据的清洗

公开数据集不等于干净数据集。第一次训练完成后,我把训练集里loss最高的样本全部导出来人工看了一遍,发现的问题比预想的多:有些图片里包含两个类别(比如火锅锅底里同时有肉片和蔬菜),有些图片主体被文字或水印遮挡,还有少量图片的标签和内容根本对不上。这一轮清洗大概剔除了不到千分之五的样本,但验证集指标提升了约一个点。别小看这一个点,在细粒度分类任务里,这可能就是模型能力过不过业务门槛的差距。

清洗流程我建议采用"训练一轮取高loss样本人工复核"的方式,而不是傻乎乎地从头翻图。因为高loss样本往往是标签错误和边界模糊样本的集中区域,人工复核效率最高。实际操作时,我在训练到第5个epoch时把训练集loss降序排列,取了前500张,大约花了一个小时就筛出了90多张有问题的图。至于那些确实模糊到连人都无法判断的样本,我的建议是直接删除,不要硬让模型去拟合人眼都分不清的东西。

3. 模型选型:迁移学习不是偷懒,是食物数据量条件下的理性选择

3.1 从零训练的成本账

聊到模型选型,经常有人跳出来说"别用预训练权重,那不算真本事"。这种说法在竞赛里也许有点道理,但在实际项目里完全站不住脚。我用这次的数据做过对比:用同样的101类数据集,从零训练ResNet-50大约需要十几个小时才能勉强收到80%出头的Top-1准确率,而加载ImageNet预训练权重做微调,只用一半时间就轻轻松松到88%以上。如果不是追求极致实验效果,这个账怎么算都是迁移学习划算。

更深一层的原因是,食物分类本质上属于细粒度视觉识别任务,需要模型在"已经会看纹理、颜色、边缘"的基础上,进一步学会区分"纹理的细微差异"和"颜色的组合模式"。预训练骨干网络已经在千万量级的通用图像上学到了基础的视觉表征,微调阶段只需要让这些表征适配食物的特殊分布。从零训练相当于让模型同时补基础视觉能力和细粒度分辨能力这两门课,数据量和训练时间都不够它毕业。

3.2 骨干网络对比:我实测的ResNet-50与EfficientNet-B0

这个项目里我主要对比了两种骨干网络,一条是ResNet-50,一条是EfficientNet-B0。选它们的原因很实际:既有公开预训练权重,部署生态又成熟。EfficientNet-B0的理论计算量更小、参数量也更少,看起来是更优的选择。但实测下来的结论没有这么简单。

骨干网络参数量验证集Top-1验证集Top-5单卡训练耗时(约)
ResNet-50约25.6M88.7%96.8%2.1小时
EfficientNet-B0约5.3M89.2%97.1%2.4小时

EfficientNet-B0确实在精度上稍占优势,但它的输入尺寸和通道缩放策略在部署阶段有点尴尬:固定输入分辨率导致推理时缩放算法对结果影响更大,而且现代GPU对EfficientNet这类深度可分离卷积的支持不如对标准卷积那么友好。如果你目标平台是移动端,EfficientNet会是不错的选择;但如果最终要跑在数据中心GPU上,ResNet-50反而更省心。权衡之后,我用ResNet-50作为主力模型跑完了全部实验,后续的部署和量化也顺理成章地围绕它展开。

3.3 分类头替换与冻结策略

加载预训练权重之后还有一个容易被忽视的细节:分类头的设计。如果直接把最后的全连接层从ImageNet的1000类换成101类,等于在微调初期随机初始化了整个分类层,梯度会猛然冲击骨干网络。我的做法是替换成一个两层的小分类头:先接一个256维全连接层加ReLU和Dropout,再接101类输出层。这样一个随机初始化的分类头只有几十万参数,微调时对骨干的冲击小得多,还能顺带带上Dropout做正则化。实测对比单层线性分类头,两层结构的验证集准确率高约0.5个百分点,而且训练过程更平稳,loss曲线几乎看不到剧烈抖动。

微调阶段我采用两段式策略:先冻结骨干网络全部参数,只训练分类头三到五个epoch,让随机初始化的分类层先学会利用预训练特征;然后解冻骨干网络,用更小的学习率全量微调。前一段的作用是避免分类头在训练初期产生过大的梯度回传,把预训练权重搅乱;后一段则让骨干的特征适配食物图像的分布。很多人直接上来就全量微调,结果要么训练不稳,要么在验证集上反复震荡,其实都是省了前一段的功夫。

4. 训练配置与超参调试:一套可以照抄的配方

4.1 优化器、损失函数与调度器

训练配置这部分我尽量给一套可以直接照抄的配方,但先讲清楚每个选择背后的逻辑。优化器我对比过SGD和AdamW。SGD加动量在传统视觉任务里地位很高,收敛稳定、泛化好,但需要配一个比较精细的学习率调度;AdamW收敛快、对学习率不敏感,尤其在微调阶段,用它几乎不用怎么调就能拿到一个不错的结果。我用AdamW配合weight decay,实测在食物分类上最终精度略高于SGD,而且省去了大量调参时间。具体设置:初始学习率3e-4,weight decay 1e-4,batch size 64。

损失函数上我建议直接上标签平滑。食物类别之间天然存在相似性,硬标签会把"水煮鱼"和"酸菜鱼"的相似特征强行对立起来,导致模型在区分模糊样本时过度自信。标签平滑等于告诉模型"不必100%确信某个类别",soft target对细粒度分类几乎总是有益的。平滑系数我用的0.1,再大一点模型会显得"过于不自信",推理时的置信度分布会变得没有区分度。

调度器用的是CosineAnnealingWarmRestarts,先线性warmup 5个epoch,再走完整的余弦退火周期。warmup的作用是让学习率从零附近逐渐爬升,避免AdamW在训练初期因为梯度统计量还没稳定就迈太大步;余弦退火则保证在训练后期学习率降到足够低,帮助模型收敛到更平滑的极小值。这套组合我已经用了很久,在多个分类任务上都稳定有效。

4.2 MixUp与EMA:细粒度分类的正则化红利

两个我强烈推荐加上的技巧是MixUp和EMA,它们对细粒度分类任务尤其友好。MixUp的思路很直白:随机取两张训练图,按一定比例混合像素,对应的标签也按同样比例混合。比如取一张"麻婆豆腐"和一张"蚂蚁上树",以0.7和0.3的比例混合后,训练标签就是0.7的麻婆豆腐加0.3的蚂蚁上树。这样做的效果是让模型在类别边界处更平滑,不至于为了硬生生区分类别而过拟合到某个训练样本的极端特征。我在实验里把MixUp的alpha参数设为0.2,验证集震荡明显减小。

EMA就更简单了:训练过程中维护一份模型参数的滑动平均,训练结束时用这份平均权重而不是最后一轮的权重做推理。它等价于在参数空间做了集成,能显著提升模型稳定性和最终精度。实现只需要在每次优化器step之后把当前参数按0.999的衰减率累加到一份影子权重里。我给项目里所有实验都挂上了EMA,验证集Top-1普遍能再涨约0.3到0.5个百分点,几乎零成本。

4.3 训练脚本要点

把上面的技巧整合起来,训练循环的核心部分长这样。我贴的是简洁版,去掉了断点续训和日志上报,保留最能体现思路的部分:

import torch import torch.nn as nn import torch.optim as optim from torch.cuda.amp import GradScaler, autocast model = create_model(num_classes=101) optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) scaler = GradScaler() ema_model = create_model(num_classes=101) ema_model.load_state_dict(model.state_dict()) ema_decay = 0.999 def update_ema(source, target, decay): with torch.no_grad(): for s, t in zip(source.parameters(), target.parameters()): t.data.mul_(decay).add_(s.data, alpha=1 - decay) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) for epoch in range(30): model.train() for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() with autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() update_ema(model, ema_model, ema_decay) scheduler.step()

有一点要提醒:EMA影子权重只在验证和推理时使用,不要在训练过程中把影子权重拷回模型参与前向计算,否则梯度会失去意义。训练日志里记录的loss是原始模型的,验证集指标最好用EMA模型单独评估一次,两者分开看才不会被数字误导。

5. 评估与错误分析:Top-1过90%之后的"但是"

5.1 为什么食物分类要看Top-5和混淆矩阵

先说一个反直觉的结论:单看Top-1准确率在食物分类场景里是远远不够的。因为食物类别之间存在大量合理的"可替代性":同一个用户拍的同一盘菜,在A餐厅菜单上叫"辣子鸡丁",在B餐厅可能叫"宫保鸡丁",模型如果按Top-1只输出一个答案,就算它在Top-5里已经正确命中,业务上仍然会被记成一次误判。所以在评估阶段,我把Top-5和混淆矩阵当成了比Top-1更重要的决策依据。

Top-5的意义在于衡量模型"知不知道候选范围":当模型给出五个预测时,正确答案是否在里面。对于营养估算这类下游任务,我完全可以直接把Top-5的概率加权作为输入,而不是强行只取Top-1。混淆矩阵则帮我定位高频错误对,比如"炸薯条"和"土豆泥"如果频繁混淆,说明模型还没有把"脆"和"软"的视觉差异学透。这些信息比一个孤零零的准确率数字有价值得多。

5.2 实测踩坑:误把"水煮鱼"当"酸菜鱼"

我印象最深的一个错误案例是模型反复把"水煮鱼"判成"酸菜鱼"。这两道菜的容器很像,都是大盘子加大量汤底,表面浮着辣椒和花椒。我第一次看loss曲线时还以为模型没有收敛好,直到我把错分样本单独导出,发现几乎一模一样的两张图,人眼都很难分辨。问题出在训练数据本身:在这个数据集里,"水煮鱼"的图片很多是满屏辣椒的版本,而"酸菜鱼"的图大多能看到汤底里的酸菜叶。模型学的不是鱼片纹理,而是"辣椒密度"和"酸菜可见度"这两个表面特征。

这个案例给我的教训非常直接:当模型的错误呈现明显的"类别对"模式时,不要急着调超参,先回去看这两类数据的可视化分布。很多时候是数据分布本身的偏差,不是模型能力不够。我当时的处理办法是重新检查了这两个类别的训练图片,按拍摄角度和配料比例做了统计,发现"酸菜鱼"类别里确实混入了一些看起来很像水煮鱼的样本。清洗之后,这对类别的混淆率下降了将近一半。

5.3 错误驱动的下一轮数据迭代

分析错误不能停在一句"数据不干净"上,还要转化为下一轮迭代的具体行动。我的流程是:训练完一版模型之后,导出验证集的混淆矩阵,把混淆率最高的前十个类别对列出来;然后针对每个类别对,分别统计它们各自的训练样本被错分成了什么;最后按这些统计结果决定下一步动作——补拍新图、删除噪声样本,还是直接合并业务上可以接受的相似类别。

这套闭环流程跑下来,模型在第二轮迭代中又提了约两个点。更重要的是,它让"提升准确率"这个模糊目标变成了一个个具体动作:补哪类数据、清哪些样本、合并哪些类别。在真实项目里,这种"错误驱动的数据迭代"带来的长期收益,远大于换一个更大的模型或者再调一轮超参。模型选型只是决定了能力上限,数据质量才决定你最后能摸到多高。

6. 落地推理:训练得再好,部署时也能翻车

6.1 推理预处理必须与训练保持一致

训练时验证集的预处理是Resize到256再CenterCrop到224,这个细节在部署阶段极其容易翻车。我第一次写推理服务时,图省事用了最朴素的Resize到224再直接送入模型,结果上线后发现预测结果和离线验证对不上,某些类别的置信度分布完全变了。排查了半天才意识到:Resize到224等于把整个画面塞进正方形,而CenterCrop是先从256边长里切出中心224区域再送入模型,两者的有效感受野完全不同。

这个坑的本质是训练与推理的预处理不一致。解决办法是上线前把所有预处理逻辑封装成同一个函数,训练、验证、推理共用一份代码。我甚至建议直接把TensorRT或ONNX的输入前处理写成和训练脚本里完全相同的操作序列,包括缩放插值方式。PyTorch默认是双线性插值,而很多推理框架默认可能不一样,这一点必须显式指定,否则图片会以微妙不同的方式变形,悄悄吃掉零点几个点的精度。

6.2 模型压缩与推理速度

模型训练完之后,我做了ONNX导出和FP16量化。导出ONNX本身没什么难度,PyTorch官方API直接转就行,但要留意输入输出的动态维度设置。在这个项目里图片是固定224x224,所以我把维度全部写死成静态,这样后续的图优化和算子融合更彻底,TensorRT还能直接定义输入尺寸。量化方面,FP16推理在数据中心GPU上是稳赚不赔的选择,精度损失几乎可以忽略,实测Top-1只掉了0.1到0.2个百分点,但吞吐量提升了将近三倍。

如果你要把模型压得更小,可以再走一步INT8量化,但食物分类这种细粒度任务对量化特别敏感。我试过一次INT8,Top-1直接掉了快一个点,最后果断放弃。量化之后模型能过业务门槛就不算白做,过不了就说明这个方向不合适,及时回头比硬撑高效得多。部署之前,一定要拿盲测集重新评估一次量化模型,别只盯着训练验证集看。

6.3 真实餐饮场景的置信度阈值策略

模型输出的原始softmax概率不能直接当"置信度"用。我上线后很快发现,模型对某些类别整体表现出过度自信,即使概率值到了0.9,也可能是错的。我在盲测集上统计了正确样本和错误样本的置信度分布,发现把接受阈值从默认的0.5提到0.75,可以过滤掉大约六成错误预测,代价是牺牲掉不到两成的低置信度正确预测。这个取舍在餐饮场景里是值得的:与其让系统信誓旦旦地说一个错误答案,不如让它承认"不确定",转人工或推给用户二次确认。

另外还有一个细节:真实场景中图片往往不是单张干净的食物特写,餐盘里可能混着其他配菜,甚至同时出现两道菜。我建了一个简单的区域提议前置模块,先检测出可能的餐盘区域,再逐个送进分类模型。整套链路串起来之后,吞吐能满足实际业务需求,且综合准确率表现完全可用。别小看这类工程化细节,它们才是模型从Demo变成产品的真正台阶。

最后分享一个我项目收尾时的个人体会:做食物分类最需要的不是花哨的网络结构,而是耐心和数据洁癖。同样的模型,我第一版精度只能到87%上下,后来靠数据清洗、增强调参、EMA和阈值策略,硬生生磨到了91%。每一个点子单看提升都不大,但叠在一起就是质变。你如果正准备做一个类似的图像分类项目,我建议把至少一半的精力放在数据端,先让数据干干净净,再谈调参优化。另外一个小技巧:训练时把每个epoch的样本图和预测结果可视化出来,放到TensorBoard里盯着看。很多时候模型什么时候开始学歪、什么时候开始收敛,看图片比看曲线直观太多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询