简介:这份面向深度学习入门者与计算机视觉实践者的食物图像识别项目包,围绕卷积神经网络(CNN)的完整落地流程展开,可用于练习从数据预处理、模型搭建到训练评估的全链路环节。压缩包共32个文件、约17.28MB,以12个Python脚本为主体,覆盖网络结构定义、图像变换、训练循环与结果输出;另含tfrecords数据文件、xml与iml工程配置、md说明文档及pyc缓存等辅助内容,目录组织便于按模块查阅。读者可据此复现卷积层、池化层与全连接层的组合搭建,理解尺寸调整、像素归一化、数据增强对泛化能力的影响,并对照Adam优化器与损失曲线排查收敛问题;也可在既有模型基础上尝试VGG、ResNet微调,体会迁移学习减少训练开销的思路。目前已有6775人学习下载,适合具备Python与TensorFlow基础、希望动手吃透图像分类流程的读者。
1. 卷积神经网络做食物图像识别:真正的难点从来不是「分类」两个字
很多人第一次接触食物图像识别,直觉都是「不就是个分类任务,卷积神经网络搭起来,交叉熵一挂,准确率自然上去」。真跑过一轮的人会发现,卡住效果的一般不是网络不够深,而是同一道菜换个盘子、换种做法、换家店就面目全非,而两盘完全不同的菜在像素上却像得离谱。卷积神经网络在食物识别里真正干的事,与其说是「把图像映射到标签」,不如说是「在烹饪方式、光照、摆盘这些干扰下,仍然抽出稳定的食材与质地特征」。搞懂卷积核在这类图像上到底学到了什么、又在哪里失效,比盲目换 backbone 有用得多。
2. 卷积核与特征层次:食物图像为什么难在类间相似、类内发散
食物图像有一个很反常识的统计特性:类内方差极大,类间方差极小。同一道「红烧肉」,家里做的、餐馆做的、外卖盒里压扁的,颜色、形状、背景都能差出十万八千里;而「红烧肉」和「糖醋排骨」在某些光照下,前几层特征几乎重合。卷积神经网络能在这类数据上工作,靠的不是「记住整张图」,而是靠局部感受野、权重共享和层次化抽象这三件事的组合。先把这三件事在食物图像上的具体表现拆开看,后面所有的训练技巧才有落脚点。
2.1 局部感受野与权重共享为什么天然契合食物纹理
一张食物图像里最有判别力的信息,大量集中在局部:油炸表面的酥脆颗粒、汤汁的高光反射、米饭的颗粒边界、青菜的叶脉走向。这些模式的特点是「小尺度、可重复、位置不固定」。3×3 或 5×5 的卷积核恰好只覆盖相邻若干像素,天然匹配这种局部性;而权重共享让同一个卷积核在整张图上滑动,于是「检测酥脆边缘」这个检测器不管出现在左上角还是右下角都用同一套参数。这两点合起来,等于把「纹理检测」从「位置」里解耦了出来,参数量还只有全连接层的零头。
但这里有个容易被忽略的细节:食物识别里位置并不是完全无关的。摆盘本身就是强判别信号——日式便当的格状分区、火锅的中央沸腾、西餐的盘中留白,这些是「全局布局」层面的信息,局部卷积核看不到。所以真正有效的食物识别网络,浅层靠局部纹理,深层必须靠足够大的感受野把布局信息收进来。感受野怎么算,直接决定模型能不能看到「整盘」。
# 感受野递推:每经过一层,有效感受野按 stride 累乘增长 def receptive_field(layers): rf, stride = 1, 1 for k, s in layers: # k: 卷积核尺寸, s: 步长 rf = rf + (k - 1) * stride stride *= s return rf # 典型骨干前几层:7x7/s2, 3x3/s2, 3x3/s1 ... print(receptive_field([(7, 2), (3, 2), (3, 1)])) # 输出 23这段递推说明一件事:三层之后单个神经元的感受野才 23 像素,而一张 224×224 的输入要覆盖「整盘布局」,必须堆到几十层或者用空洞卷积、全局池化来补。这解释了为什么浅层特征图对「这是什么食材」敏感、对「这是什么菜」迟钝——不是网络笨,是它还没看到足够大的范围。调参时如果发现相似菜品总是互相吞,先怀疑感受野不够,而不是先加数据。
2.2 从边缘到食材组合:特征层次到底怎么长出来的
卷积网络的特征层次不是玄学,是可以逐层观察的。第一层通常学到的是方向边缘和颜色块:橙红色的大块区域、白色的高光点、绿色的细长条。第二、三层开始组合出纹理基元:颗粒感、条纹感、光泽感。再往深,特征图上的高响应区域开始对应「一块肉」「一片菜叶」「一勺酱汁」这种食材级概念。最后一层才把这些食材的出现与否、相对位置组合成「这道菜是什么」。
这个层次结构对食物识别有一个直接后果:判别信息是分层分布的。区分「清蒸鱼」和「红烧鱼」,关键在中层的颜色与光泽特征;区分「鱼」和「鸡」,关键在深层的整体形状与纹理组合。所以做错因分析时,要按层去看,而不是只看最终 softmax。常见的做法是把中间层特征图拿出来做可视化,看模型在某张图上到底「盯」住了哪里。如果高响应区域落在盘子边缘或背景桌布上,说明模型学到了捷径特征(shortcut),这时候再堆数据也是白搭。
import torch import torch.nn as nn # 只取骨干前几层,观察浅层响应 stem = nn.Sequential( nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False), nn.BatchNorm2d(64), nn.ReLU(inplace=True), ) x = torch.randn(1, 3, 224, 224) # 模拟归一化后的单张食物图 with torch.no_grad(): feat = stem(x) print(feat.shape) # [1, 64, 112, 112],64 个通道即 64 种浅层检测器输出张量的 64 个通道,可以理解成 64 个「边缘/颜色检测器」的响应图。实际排查时把每个通道归一化后叠加回原图,就能看到某个通道到底在响应酥脆表皮还是盘沿高光。这一步做一次,比对着 loss 曲线猜半天有用。
2.3 平移不变性的边界:为什么食物识别不能只靠它
卷积加池化带来的平移不变性,常被当成万能优点。放到食物识别里,它其实是把双刃剑。一方面,同一道菜出现在画面任何位置都该被认出来,这需要平移不变;另一方面,摆盘、分区、食材的相对位置本身就是判别信息,过度池化会把这些信息抹平。典型的翻车场景是:模型对「把菜换个位置」很鲁棒,但对「把两道菜的食材互换摆盘」完全无感,因为深层特征已经被全局池化压成了「食材出现与否的集合」,丢掉了空间关系。
处理这个矛盾有两条常见路线。一条是控制池化强度,在深层保留稍大的空间分辨率,用全局平均池化之前先做一次带位置编码的聚合;另一条是在数据增强里刻意制造「位置扰动但语义不变」的样本,让模型学会区分「位置变了但菜没变」和「位置变了菜也变了」。两条路线的取舍点在于:前者加计算量,后者加数据工程成本。小数据集上我一般倾向前者,因为数据增强很难覆盖所有摆盘变化;数据量足够时后者更划算。
3. 数据管道与增强策略:长尾分布和颜色陷阱才是精度天花板
网络结构决定上限,数据管道决定能不能摸到上限。食物数据集几乎必然是长尾的:少数高频菜品样本成千上万,大量地方菜、时令菜只有几十张。更麻烦的是颜色——食物语义高度依赖颜色,而最常用的颜色增强恰好会破坏语义。这一章把长尾处理、颜色增强的边界、以及 MixUp/CutMix 在食物图像上的实际表现讲清楚,这些细节决定了模型是真的学到了「菜」,还是学到了「盘子颜色」。
3.1 长尾分布:类别不均衡才是食物数据的常态
真实采集的食物数据,类别分布几乎不可能均匀。热门菜系、连锁餐厅的招牌菜样本多,地方小吃、季节菜样本少,比例到 100:1 都不夸张。直接用交叉熵训练,模型会迅速倒向头部类:因为把长尾样本判成头部类,loss 下降最快。表现出来就是整体 accuracy 很好看,但长尾类的 recall 惨不忍睹,混淆矩阵右下角一片空白。
处理长尾常见有三类做法。第一类是重采样:对尾部类过采样,或对头部类欠采样。过采样容易过拟合少数样本,欠采样浪费数据,通常配合强增强一起用。第二类是代价敏感:给尾部类更高的 loss 权重,或者用 Focal Loss 这类降低易分样本权重的损失。第三类是解耦训练:先用均衡采样训特征,再冻结骨干、只用类平衡策略微调分类头。第三类在食物识别上效果往往最稳,因为食物特征本身跨类共享度高,分类头的偏置才是长尾的主要来源。
import torch import torch.nn as nn class FocalLoss(nn.Module): def __init__(self, alpha=None, gamma=2.0): super().__init__() self.alpha = alpha # 每类权重,可传入按频率反比计算的张量 self.gamma = gamma # 聚焦参数,越大越压低易分样本 def forward(self, logits, target): ce = nn.functional.cross_entropy(logits, target, reduction='none') pt = torch.exp(-ce) # 预测正确的概率 loss = (1 - pt) ** self.gamma * ce # 难样本权重更大 if self.alpha is not None: loss = self.alpha[target] * loss return loss.mean()gamma控制对易分样本的压制力度,食物识别里常用 1.5~2.0;alpha是按类频率反比算出来的权重张量,尾部类权重大。注意这两个参数不要同时开到很大,否则训练早期梯度会被少数极难样本主导,loss 震荡得厉害。实际调的时候先固定gamma=0只开alpha,确认长尾 recall 有提升,再逐步加gamma。
3.2 颜色增强的双刃剑:色调抖动会破坏食物语义
几乎所有图像任务的默认增强里都有 ColorJitter,四个参数 brightness、contrast、saturation、hue 一起抖。放到食物图像上,前三个基本安全,hue 是雷区。「红烧」和「清蒸」的区别,很大一部分就是色调;hue 抖大了,等于把红烧样本增强成了清蒸样本,标签直接错乱。更隐蔽的是,模型会学到「色调不可靠」,转而依赖盘子形状、背景纹理这些无关特征,验证集一换场景就崩。
我的经验是把 hue 压到很小甚至关掉,brightness、contrast、saturation 可以放开一些,因为餐厅灯光和拍摄设备带来的差异主要落在这三个维度。另外,RandomResizedCrop 的 scale 下限不要设太低,食物主体通常占画面较大比例,裁得太狠会把整道菜裁成一块纹理,语义就丢了。
import torchvision.transforms as T train_tf = T.Compose([ # scale 下限 0.6,避免把整道菜裁成局部纹理 T.RandomResizedCrop(224, scale=(0.6, 1.0)), T.RandomHorizontalFlip(p=0.5), # hue 压到 0.05,刻意保护食物颜色语义 T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])归一化用的均值和方差是通用图像统计值,食物图像整体偏暖偏亮,直接沿用通常没问题,但如果数据集里大量暗光外卖图,可以重新统计一遍。hue=0.05是经验上限,再大就要逐类验证有没有语义漂移。这一步没有捷径,只能抽一批尾部类样本肉眼过一遍增强后的图。
3.3 MixUp 和 CutMix 在食物图像上的实际效果与风险
MixUp 把两张图按比例线性混合,CutMix 把一张图的矩形区域贴到另一张图上。这两个方法在通用分类上很有效,但在食物识别上要小心。MixUp 混合两张食物图,视觉上会产生「糊成一团」的中间态,这种图像在真实世界里几乎不存在,模型学到的边界可能偏离真实分布。CutMix 的风险更具体:把一块红烧肉贴到一盘青菜上,模型被要求同时输出两个标签,但真实场景里这种组合要么不存在,要么应该被识别成「拼盘」而非两个独立类。
实际用下来,CutMix 在小比例(贴图面积 10%~20%)时对抑制过拟合有效,大比例会明显伤害精度。MixUp 的 alpha 也不宜过大,0.2 左右比较稳。更好的替代方案是做「同菜不同做法」的配对增强:从同一类里取两张不同来源的图做混合,标签不变,这样既增加了类内多样性,又不引入虚假的跨类组合。这个思路在食物识别上比通用 MixUp 更贴合数据特性。
4. 网络结构与迁移学习:从骨干选型到部署的取舍链条
食物识别的开源实现里,backbone 从 LeNet 到残差网络再到各种轻量结构都有人用,选型依据往往不是「谁在榜单上高」,而是「数据量多大、部署在什么设备上、延迟预算多少」。这一章把迁移学习为什么在食物识别上几乎是必选项、BN 和全局平均池化各自解决什么问题、以及轻量骨干的三角取舍讲透。选型错了,后面所有调参都是在错误的地基上修修补补。
4.1 从 LeNet 到残差网络:为什么食物识别偏爱迁移学习
食物数据集的标注成本很高,类别动辄成百上千,但每类样本量往往只有几十到几百。在这种规模下从零训练一个深层网络,过拟合几乎不可避免。迁移学习的价值在于:在通用图像数据上预训练出来的浅层和中层特征(边缘、纹理、颜色块、局部形状),对食物图像同样有效,这部分特征不需要重新学;需要微调的只是深层和分类头,参数量小得多,收敛快,对数据量要求也低。
实践中常见的做法是冻结前若干层、只训后几层加分类头,或者全部解冻但用很小的学习率做微调。冻结层数的选择要看源域和目标域的差异:通用图像和食物图像在浅层差异不大,冻结前 1/3 通常安全;但食物图像的纹理统计和通用图像有偏移,冻太多会导致中层特征不适配。我一般先全解冻、用小学习率(骨干 1e-5、分类头 1e-3 这种分层学习率)跑一轮,看验证集曲线,再决定要不要冻结。
残差连接在这里的作用容易被低估。食物识别的判别信息分布在多个尺度上——颜色在中层、形状在深层、纹理在浅层,残差连接让梯度能跨层回传,浅层的纹理特征不会被深层的语义损失淹没。没有残差的深层网络在食物数据上训起来,经常出现「浅层特征学得还行、深层反而退化」的现象,本质是梯度路径太长。
4.2 BN 层与全局平均池化:参数量与部署的取舍
BN 层在食物识别训练里几乎是标配,但它的行为依赖 batch size。训练时 batch 太小(比如单卡 8 张),BN 的滑动统计量估计不准,验证集精度会明显低于训练集。这时候要么加大 batch,要么换 GroupNorm、LayerNorm 这类不依赖 batch 的归一化。部署时 BN 会被折叠进卷积,不增加推理开销,这点不用担心;真正要担心的是训练和推理的归一化行为不一致导致的精度落差。
全局平均池化(GAP)替代全连接层,是另一个常被当成「标准操作」但其实有取舍的设计。GAP 把每个通道压成一个数,参数量几乎为零,过拟合风险低,这是优点。缺点是丢掉了空间信息,而食物识别里摆盘、分区恰恰是判别信号。一个折中做法是 GAP 之前保留稍大的特征图(比如 7×7 而不是 1×1),或者用 GeM(广义平均池化)这类可学习参数的池化,让模型自己决定对高响应区域的聚焦程度。GeM 的参数量只多一个标量,代价很小。
import torch import torch.nn as nn class FoodClassifier(nn.Module): def __init__(self, backbone, num_classes, drop=0.3): super().__init__() # 去掉骨干原有的分类头,只保留特征提取部分 self.features = nn.Sequential(*list(backbone.children())[:-2]) self.pool = nn.AdaptiveAvgPool2d(1) # 全局平均池化 self.drop = nn.Dropout(drop) in_dim = backbone.fc.in_features if hasattr(backbone, 'fc') else 2048 self.fc = nn.Linear(in_dim, num_classes) def forward(self, x): x = self.features(x) x = self.pool(x).flatten(1) # [B, C, 1, 1] -> [B, C] return self.fc(self.drop(x))AdaptiveAvgPool2d(1)把任意空间尺寸压成 1×1,这是 GAP 的标准写法;drop放在池化之后、分类之前,抑制分类头过拟合。如果发现相似类混淆严重,可以把pool换成保留 2×2 或 3×3 的自适应池化再接展平,用少量参数换空间信息。这个改动很小,但对细粒度食物分类的提升经常比换 backbone 明显。
4.3 轻量骨干的选型:精度、延迟、内存的三角
落到实际部署,食物识别经常跑在手机或边缘设备上,延迟和内存是硬约束。深度可分离卷积把标准卷积拆成逐通道卷积加逐点卷积,参数量和计算量都降一个量级,代价是精度略降、对小目标的响应变弱。食物图像主体通常较大,这个代价一般可以接受。输入分辨率也是关键杠杆:224×224 降到 192×192,计算量降约 27%,精度损失在食物这种大主体场景里往往只有一两个点。
选型时我习惯列一张三角表:精度、单帧延迟、模型体积,三个维度里只能保两个。要极致延迟就选深度可分离加低分辨率;要精度就选标准卷积加高分辨率,接受更大体积;要平衡就先定延迟预算,再在该预算内找精度最高的组合。这张表要在目标设备上实测,不能拿论文里的数字直接套——不同推理框架、不同量化策略,延迟能差出一倍。
5. 常见排查:食物识别训练与落地时最容易翻车的五件事
模型训不出来、训出来部署又不对,是食物识别项目里最高频的两类问题。下面五条是按「现象 → 原因 → 解决」整理的踩坑记录,都是实际项目里反复出现的,不是理论推演。每一条都对应一个可操作的检查点,遇到问题先按顺序对一遍,能省掉大量盲目调参的时间。
5.1 训练集准确率冲到 99%,验证集卡在 60% 不动
现象:训练 loss 一路下降,训练集 accuracy 接近满分,验证集 accuracy 从第三个 epoch 起就不动了,再怎么加正则也没用。
原因:最常见的是数据划分泄漏——同一道菜的连拍图、同一家店同一盘菜的多角度图,被随机划分到了训练集和验证集,模型在验证集上其实是在「认图」而不是「认菜」。其次是增强太弱,训练集和验证集的分布差异没被覆盖。真正的过拟合反而少见,因为食物数据量通常不足以让模型在训练集上过拟合到 99% 还泛化不了。
解决:按菜品 ID 或拍摄场景分组划分,保证同一来源的图只出现在一个集合里;增强强度按验证集精度反馈调,不要一次开到最大;先确认划分无误,再考虑加正则。这个顺序不能反,否则会在错误的前提下加一堆没用的 trick。
5.2 混淆矩阵里那对「长得像」的类别总是互相吞
现象:整体 accuracy 还行,但某两类(比如两种红烧做法的肉、两种清蒸鱼)的互相误判占了总错误的 40% 以上,per-class recall 在这两类上明显塌陷。
原因:这两类在中层特征(颜色、光泽)上高度重合,深层特征又不足以区分细节;或者其中一类的标注本身有噪声,把边界样本标错了,模型学到的是矛盾的监督信号。
解决:先把这两类的样本抽出来肉眼过一遍,清洗标注噪声;再针对性加强细粒度特征——提高输入分辨率、在深层保留更大特征图、或者引入注意力模块让模型聚焦食材局部而不是整盘。如果两类确实在视觉上难分,考虑合并成一个粗类,比强行二分类更符合数据实际。
5.3 训练端精度正常,推理端掉一大截
现象:验证集 accuracy 75%,导出模型到推理端跑同一批图,accuracy 掉到 60% 出头,单张对比还能看到 softmax 分布明显偏移。
原因:预处理不一致,这是最高频的坑。训练时用的 resize 插值方式、归一化均值方差、通道顺序(RGB/BGR)、甚至 crop 的位置,和推理端只要有一项对不上,精度就会掉。其次是 BN 的 train/eval 模式切换、量化校准数据分布不对。
解决:把训练端的预处理代码原样搬到推理端,用同一张图分别过两端,逐层对比输出,定位到第一个不一致的层。这个对比脚本值得花半天写,后面每次换框架都能复用。
5.4 数据增强开了之后精度反而下降
现象:加了 RandomResizedCrop 和 ColorJitter 之后,验证集精度不升反降,训练 loss 收敛变慢。
原因:增强强度超过了数据本身的容忍度。食物图像对 hue 抖动、大比例裁剪特别敏感,前面 3.2 节讲过;另外 scale 下限设太低,会把整道菜裁成一块无法辨认的纹理,标签语义已经漂移。
解决:逐项 ablation,一次只开一个增强,看验证集变化;hue 先关掉,scale 下限从 0.8 起调;用增强后的图抽样目检,确认语义没变。增强不是越多越好,食物识别里「保语义」比「增多样性」优先级更高。
5.5 类别不均衡下 accuracy 的假象
现象:模型报告 85% accuracy,看起来不错,但实际用起来长尾菜几乎全错,用户反馈「新菜识别不了」。
原因:accuracy 在长尾分布下会被头部类主导,尾部类的错误被平均掉了。模型学会了「全猜头部类」也能拿到不低的分数。
解决:评估指标换成 macro-F1 和 per-class recall,按类看 confusion matrix;训练侧用类平衡采样或代价敏感损失;上线前专门构造一个长尾测试集,按类统计召回。只看 accuracy 的项目,长尾问题一定会拖到线上才暴露。
6. 设计取舍:精度、延迟、可维护性三者只能保两个
食物识别落地时,最难的从来不是把某个指标刷高,而是在精度、延迟、可维护性之间做取舍。追求极致精度,就上大骨干、高分辨率、复杂增强,代价是推理慢、模型大、调参成本高;追求极致延迟,就上深度可分离卷积、低分辨率、量化,代价是长尾类精度掉得更快;追求可维护性,就把数据管道和模型解耦、把预处理固化成配置,代价是前期工程投入大、灵活性略降。这三个维度没有全局最优解,只有针对具体场景的局部最优。
| 维度 | 激进方案 | 保守方案 | 适用场景 |
|---|---|---|---|
| 骨干 | 标准卷积深网络 | 深度可分离轻量网络 | 服务端批量识别 / 端侧实时 |
| 输入分辨率 | 320×320 以上 | 192×192 左右 | 细粒度菜系区分 / 大类识别 |
| 增强强度 | 强增强加 CutMix | 仅几何加温和颜色 | 数据量大 / 数据量小且标注精 |
| 池化 | 保留 3×3 再展平 | 全局平均池化 | 相似类多 / 类别差异明显 |
验证取舍是否合理,靠的不是单点精度,而是一套分层的验证流程:先在按场景分组的验证集上看 macro-F1,再在端侧设备上跑一遍预处理一致性测试,最后构造一个长尾测试集看 per-class recall。三步都过,才算真的能上线;任何一步掉链子,都要回到对应章节找原因,而不是继续调学习率。
有一个习惯我保持了很
本文还有配套的精品资源,点击获取