ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

卷积神经网络与迁移学习:食物图像识别实战与数据增强避坑指南

卷积神经网络与迁移学习:食物图像识别实战与数据增强避坑指南

简介:基于卷积神经网络的食物图像识别项目资源包,面向深度学习初学者与CV开发者,解决食物图片分类任务从建模到部署的完整流程问题。压缩包共32个文件,总大小17.28MB,主体为12个Python脚本,分别承担数据预处理、模型构建、训练与测试等功能;另含tfrecords样本数据、模型权重、项目配置及说明文档,结构清晰便于按步骤运行。已有6701人学习。资源内容覆盖CNN卷积层、池化层、全连接层及激活函数的代码实现,并包含Dish_CNN工程完整复现,读者可基于自带数据训练识别意大利面、披萨、寿司等类别,也可加载预训练权重快速验证效果。适合具备Python基础、希望结合TensorFlow实践图像识别的读者,亦可用于课程设计或竞赛入门。

1. 食物识别比“认猫”难在哪:难的不是卷积,是数据分布

先给一个反直觉的结论:在图像识别任务里,认出一只猫比认出一道菜容易得多。猫有胡须、耳型、体态这些跨环境稳定的结构线索;一道菜在一张照片里留下的,只是盘中食材的颜色、纹理和大致切法。同一道菜换个滤镜、换个摆盘、换个光源,像素分布的变化可能比不同菜之间的差异还大;反过来,宫保鸡丁和鱼香肉丝这种“看起来差不多”的类别,又会在高层语义上互相拉扯。

卷积神经网络要解决的,就是从“像素太像”和“类别太近”这两重压力下,找出真正可判别的视觉证据。很多人拿到一份食物图像识别项目,第一反应是“网络不够深、数据不够多”,实际跑起来才发现,数据划分、类别不均衡、预处理一致性这些环节才是决定最终精度的主要因素。这也是这篇文章要拆开讲的东西:CNN 在这类任务里到底怎么提特征,迁移学习为什么有效,以及哪些坑会让人反复翻车。

理解这些机制,比盲目再堆几层卷积都值。下面从最底层的特征提取说起。

2. 卷积特征提取:浅层看颜色、深层看食材,感受野一层层放大

2.1 卷积核在学什么:从边缘、纹理到“食材部件”

卷积的运算本质不复杂:一个可学习的滤波器在输入特征图上滑动,每个位置做一次内积,得到一个新的响应值。参数共享让同一组滤波器能出现在图像的任何位置,这就是“不管菜放在盘子左边还是右边,同样的纹理都能被激发”的原因。但真正值得追问的是:这些滤波器到底在响应什么?

在一个用食物数据训练好的网络里,浅层卷积核学到的往往是颜色块和基础边缘。食物识别里最有用的浅层线索是“酱汁的色相”和“食材颗粒的纹理”:红亮的油光、青椒的深绿、蒜末的浅黄颗粒,这些视觉基元不需要语义就能区分开。中层卷积核开始把这些基元组合成部件级概念:“肉块”“花生米”“辣椒段”“葱花”,这是在浅层边缘之上叠加出来的组合模式。到了深层,网络把部件组合成整道菜的判别结构,比如“鸡丁和花生同时出现且被深色酱汁包裹”指向宫保鸡丁,“肉丝配木耳和笋丝”指向鱼香肉丝。

这里有个很多人会忽略的点:没有任何一行代码告诉网络“什么是肉块”“什么是花生”,这些概念完全由梯度反传自发组织出来。把某层 feature map 可视化,或者用激活最大化(activation maximization)去放大某个滤波器最“喜欢”的输入模式,你会看到清晰的分层:底层是条纹和色块,中层是食材碎片,顶层是接近完整食物的组合。这就是表示学习的核心——特征不是人工设计的,是数据分布逼出来的。这也解释了为什么食物数据集的质量直接决定特征质量,网络结构反而不是瓶颈。

2.2 感受野与池化:平移不变性为什么对一盘菜这么重要

感受野是理解 CNN 深度的第一把钥匙。它定义的是:某一层输出特征图上的一个点,对应输入图像上多大的区域。感受野按层递推,常见公式是 rₖ = rₖ₋₁ + (kernel_size − 1) × stride 的累积效果。举个例子,如果只用 3×3 卷积堆三层的网络,最后一层的感受野只有 7×7,也就是说一个输出单元只能“看”到输入图像里 7×7 像素的信息。对于食物识别,这个范围只能看到“一小片红色”或“几粒米”,根本看不到“肉丁配花生”这种跨区域的组合证据。

池化在这里的角色常被低估。MaxPool2d 用 2×2 窗口、步长 2 做下采样,特征图分辨率减半,但每个输出单元对应到输入上的范围立即扩大一倍。更重要的是,最大池化带来对局部平移的鲁棒性:某个食材轻微偏移几个像素,只要还在池化窗口内,最大值基本不变。放到食物场景里,就是“菜被摆歪了”“手机斜拍了”“盘子没放正”都不该影响最后的判断。这是 CNN 比全连接网络更适合图像的根本原因之一。

在真实项目里,ResNet、MobileNet 这类结构的参数量差异很大,但骨干设计都是同一个套路:前几层分辨率高、通道少,保住边缘纹理;后几层分辨率低、通道多,扩大感受野、抽象出整体结构。判断一个网络适不适合食物识别,可以先看它的感受野是否覆盖到图像中“成菜”的尺度,而不用盲目追求大模型。

2.3 跑通前向:一个能打印每层 shape 的微型 CNN

为了把上面的机制落到可验证的代码里,下面这个微型 CNN 足够说明问题。它没有用任何花哨结构,但前向过程完整演示了“通道数翻倍、分辨率减半”这个标准节奏。

import torch import torch.nn as nn class MiniFoodCNN(nn.Module): def __init__(self, num_classes=20): super().__init__() # 浅层:抓颜色块、边缘,分辨率高信息细 self.block1 = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2) # 112x112 ) # 中层:把边缘组合成食材部件 self.block2 = nn.Sequential( nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2) # 56x56 ) # 高层:抽象食材组合关系,感受野覆盖大半张图 self.block3 = nn.Sequential( nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2) # 28x28 ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), # 不管输入多大,压成 1x1 nn.Flatten(), nn.Linear(128, num_classes) ) def forward(self, x): x = self.block1(x); print("after block1:", x.shape) x = self.block2(x); print("after block2:", x.shape) x = self.block3(x); print("after block3:", x.shape) x = self.classifier(x) return x model = MiniFoodCNN(num_classes=20) dummy = torch.randn(1, 3, 224, 224) # 模拟一张 RGB 食物图 print(model(dummy).shape)

运行这个脚本,输出依次是[1, 32, 112, 112]、[1, 64, 56, 56]、[1, 128, 28, 28],最后分类器输出[1, 20]。每一组 Conv+BN+ReLU+Pool 做的事情可以拆开看:Conv 负责在保持分辨率的前提下提取局部模式,BN 把激活分布拉回零均值附近避免梯度震荡,MaxPool 丢掉一半空间精度、扩大感受野。

224×224 这个输入尺寸不是随便定的。常见预训练模型都在这个分辨率下设计,移动端 FP32 推理一块 224×224 的图,计算量还能接受;再往上提到 512×512,食材纹理更清楚,但训练时间和显存占用几乎翻到四倍。AdaptiveAvgPool2d 是个容易被忽略的设计:它让网络接受任意输入尺寸,最后统一压成 1×1 向量再接全连接层,这样分类头的参数就不会因为输入尺寸变化而失效。真项目里的 ResNet 就是把上面这三个 block 重复加深加宽,机制上完全一致。想验证感受野递推,可以在每个 block 后面打印一下当前层能回看的输入范围,你会看到最后一个 block 的输出单元实际覆盖了整张盘子。

3. 数据分布是主战场:细粒度差异和类别不均衡怎么吃掉精度

3.1 细粒度识别:为什么模型会把两道类似的菜认成同一个

通用图像分类处理的是“猫 vs 狗 vs 汽车”这种类别间距很大的问题,一个全局轮廓特征就够用了。食物识别是典型的细粒度识别:类别之间共享大量视觉元素。宫保鸡丁和鱼香肉丝都有鸡丁、花生、葱段,区别可能只在酱汁的颜色深浅和配菜的切法;红烧肉和东坡肉同样是红色方块状肉块,判别证据是肥瘦比例和汤汁光泽度。这意味着模型的深层输出向量在两类上几乎重合,决策边界只能靠很小区域的局部证据撑开。

细粒度任务里最容易出现的失败模式,是模型学到“捷径”。如果训练集里辣味菜都装在红色盘子里,模型很可能用“红色盘子”而不是“辣椒和花椒”做判断;如果米饭照片总是配木质桌面,背景纹理就成了隐性分类器。这类模型在测试集上可能精度不低,但换个场景立刻崩掉。验证模型到底在看什么,常用梯度加权类激活映射把决策区域叠加回原图:如果高亮区域集中在菜本体之外,比如盘子边缘、筷子、桌布,基本可以确认模型学歪了。

这也是为什么食物识别任务里“深”不一定对。更深的网络能提供更大的感受野和更细的局部抽象,但在数据量不够大时,深网络更容易把捷径也记住。常见做法是让网络在中等深度下配合注意力机制或更大的感受野,而不是无脑加到几十层。理解了细粒度这个本质,再看数据增强、迁移学习,思路就清晰了:一切手段都是为了让模型把注意力压到真正的食材证据上。

3.2 类别不均衡:用加权损失和 focal loss 修正梯度方向

真实食物数据集几乎都是长尾分布:米饭、面条这类主食样本可能有几千张,某些地方菜只有几十张。很多人只看整体准确率,比如 88%,但把混淆矩阵拉出来才发现,模型对头部类别几乎全对,尾部类别几乎全错。机制层面看,softmax 交叉熵的梯度是所有类别样本共同累加的,头部类别样本多,在每一步更新里占据主导,尾部类别得到的有效梯度被稀释,学不到稳定的判别特征。

修正方向有两类。第一类是样本加权:给每个类别一个权重,常见取 w_c = 1 / sqrt(freq_c)。取倒数的平方根而不是直接倒数,是为了防止极端类别被给的权重过大、导致训练震荡;freq_c 是该类别样本数占总样本数的比例。第二类是 focal loss,它在标准交叉熵上乘一个调制因子 (1 − p_t)^γ,其中 p_t 是模型对真实类别的预测概率。样本分得越对,p_t 越接近 1,调制因子越接近 0,损失贡献被压低;那些分不清的尾部样本,p_t 小,梯度就能保留下来。γ 取 2 在细粒度任务里通常比较稳。

配合这两类修正,评估口径也要换。整体 accuracy 会被头部类别淹没,宏平均 F1、宏平均召回率对尾部类别更敏感。一个项目如果只报告 accuracy 不报告宏平均,多半是有不均衡问题没处理干净。训练时监控宏平均 F1 做早停,比只看 loss 曲线可靠得多。

3.3 数据增强:CutMix 如何逼模型看“局部证据”

数据增强在食物识别里不只是防过拟合,它直接改变模型学到什么。几何增强(随机旋转、翻转)很好理解,菜不会被摆得绝对端正,模型需要容忍这些变化。颜色增强要小心:食物颜色是核线索,HSV 做轻微扰动可以模拟不同餐厅的灯光差异,但扰动太大会破坏“牛肉是红的、菜叶是绿的”这种真实语义,模型反而学到错误的颜色映射。

mixup 和 CutMix 这两类区域混合增强,机制上更有意思。mixup 在两张图之间做像素线性插值,标签也按比例线性混合,强迫网络学习线性决策边界而不是记忆个别样本。CutMix 则是把 A 图里的一块矩形区域剪贴到 B 图上,标签按面积比例混合。对食物识别,CutMix 有一个天然优势:一桌菜、一碗饭配一道菜,现实里经常一张图里出现多个类别,CutMix 生成的训练样本恰好模拟了这种“局部证据竞争”场景。模型不能只看全局配色,必须在局部区域独立做出判断,这直接缓解了 3.1 节说的细粒度证据被全局平均淹没的问题。

增强强度是超参,不是越大越好。随机擦除(Random Erasing)如果擦除面积太大,可能把酱汁或配料这些判别区域整个抹掉,模型被迫去学背景,这就是增强失真。一个有用的检查方法:把增强后的样本可视化一批,拿给没做过训练的人看,如果人眼都看不出这是什么菜,那模型大概率也学不到有效特征。后面第 5 章会专门讲这个坑的具体排查方式。

4. 迁移学习:ImageNet 预训练权重为什么能在食物上 work

4.1 预训练特征的两段式结构:底层通用、顶层专用

大规模数据集上预训练出来的卷积网络,其内部特征是分层的:前几层学到的是边缘、颜色统计、基础纹理,这些视觉基元不依赖任何具体任务;中层开始出现物体部件;后几层才逐渐对应完整物体的语义。食物图像和自然图像在浅层、中层共享大量结构——它们都是同一套光学成像规则下产生的自然照片,光照、反光、遮挡、纹理形成方式是一致的。所以预训练权重里那部分通用特征可以原样复用。

关键是“哪一层开始变得任务相关”。对于食物识别,分类头是必须换掉的:原来输出 1000 个类别概率的全连接层,需要改成输出自己类别数的线性层。这个新分类头是随机初始化的,它是整个网络里唯一没有“经验”的部分,因此训练初期它的梯度特别大。这也是为什么微调比从头训练更稳定的原因:从头训练要让一个随机初始化的网络在小数据集上重新发现“边缘→纹理→部件”的完整层级,数据量根本不够;预训练权重已经把这条最难走的路走完了,剩下只是把高层语义重新对齐到食物类别上。

4.2 冻结与解冻:按数据量决定主干网络的梯度开关

微调不是简单地把所有参数一起训练。小数据集最怕的是随机初始化的分类头带着大梯度反向传播,把主干里精心学到的通用特征冲乱。常见策略是按数据量决定梯度开关:

数据量冻结范围主干学习率建议
几百张冻结整个主干不更新只训练分类头,配合强增强
几千张冻结前一半,微调后一半1e-4分两阶段解冻
几万张全部解冻1e-4 ~ 3e-4全量微调,配早停

冻结不是把参数删掉,而是把参数的 requires_grad 设为 False,让优化器跳过它。解冻顺序也有讲究:先训分类头,等分类头稳定了,再打开高层;高层稳定了,再打开中层。下面的代码演示了最常见的两阶段实现:

import torchvision.models as models import torch.nn as nn backbone = models.resnet18(pretrained=True) num_classes = 20 backbone.fc = nn.Linear(backbone.fc.in_features, num_classes) # 第一阶段:冻结全部主干,只训练分类头 for name, param in backbone.named_parameters(): if not name.startswith("fc"): param.requires_grad = False # 第二阶段:解冻 layer4,只让最顶层主干参与微调 def unfreeze_from(prefix): for name, param in backbone.named_parameters(): if name.startswith(prefix): param.requires_grad = True unfreeze_from("layer4")

这段代码的关键点在于 named_parameters 的命名规则。fc对应分类头,layer4是 ResNet 最后一个残差阶段。解冻 layer4 后,优化器需要单独给它的参数配小学习率,因为它的位置最接近新分类头,受随机初始化的影响最大,最容易学偏。如果数据量允许解冻到 layer3,学习率还要再小一点。我在实际项目里见过不少“全量解冻 + 统一学习率”直接翻车的情况,loss 曲线一路飙升,最后只能重新加载预训练权重再来,这个坑在第 5 章会细说。

提示:冻结阶段也要对分类头用相对大一些的学习率,比如 1e-3;主干解冻后统一降到 1e-4。分开管理两组参数能省很多调参时间。

4.3 微调参数:学习率、batch size、正则项联动

微调的学习率不能照搬从头训练的经验。预训练主干已经接近一个局部最优点,步长太大一步就跨出去了。常见设定是:主干 1e-4 量级,新分类头 1e-3 量级。这种不对称是有原因的,分类头是随机初始化,需要相对快的速度收敛;主干已经稳了,只能小步修整。不这样做,主干特征会在几个 epoch 内被大梯度“洗掉”,模型退化成一个从头训练的小网络。

warmup 在微调里尤其重要。刚开头的几个 step,随机分类头的梯度大,直接贴上去会干扰主干预训练特征。先用 3~5 个 epoch 把学习率从接近 0 线性升到目标值,让分类头先站稳,再让主干做精细调整。反向的 cosine 退火在后期也很有用:学习率平滑降到很低,让模型在局部最优附近收敛,而不是在边界震荡。

优化器方面,SGD 带动量在微调任务里仍然稳,但 AdamW 配合 cosine schedule 在细粒度任务里越来越常见。batch size 的影响比想象中大:BN 层的统计量依赖 batch 的多样性,batch 太小(比如 8)统计量噪声大,模型容易震荡;显存允许时尽量用 32 或 64。但这三条是联动的:大 batch 配大学习率是通用的 scaling rule,小 batch 配大学习率基本必炸。最后的兜底是早停,监控验证集的宏平均 F1,连续多个 epoch 不涨就停,而不是死磕训练集 loss。

5. 常见问题排查:食物识别训练中的五个翻车现场

5.1 验证集精度虚高,一上测试就崩:数据泄漏

现象:验证集准确率 98%,训练完信心满满,拿真实拍摄的照片一测只有 70%。

原因:数据划分太随意。很多食物图片来自视频帧或同一批探店素材,同一个菜在不同角度、不同光线下的连续帧被随机拆进了训练集和验证集,验证集里全是训练集的“近亲”。模型在验证集上不是在泛化,是在回忆几乎一样的画面。

解决:按“来源批次”分组划分。同一道菜的所有图片只进训练集或只进验证集,而不是按文件名随机切。划分完成后抽样检查训练集和验证集之间的最近邻图像,如果发现大量像素级相似的图对,就说明泄漏还在。这一步做完再谈模型调优。

5.2 整体 loss 在降,冷门类的召回始终上不去:类别不均衡

现象:训练曲线很漂亮,整体准确率 86%,但看每一类的召回率,米饭、面条这类大类别 95%,某些地方菜不到 10%,模型几乎把所有样本都预测成了头部类别。

原因:softmax 交叉熵的梯度被大类别主导,冷门类样本少,贡献的梯度被淹没。整体 loss 下降掩盖了尾部类别毫无进展的事实。

解决:先给损失函数加类别权重,w_c = 1 / sqrt(freq_c),或换成 focal loss,把注意力压向难分的尾部样本。评估指标同步换成宏平均 F1 和宏平均召回率,并且每个 epoch 都打印每个类别独立的准确率召回率,盯着尾部类别的曲线看有没有抬头的趋势。只看整体指标是发现不了这个问题的。

5.3 增强加过头,真实场景识别率反而下降:增强失真

现象:训练集上加 CutMix、Random Erasing 之后,验证集确实涨了几个点,但部署到实际场景,对轻微模糊、亮度正常的图反而变差。

原因:Random Erasing 擦除面积太大,把酱汁、配料这些核心判别区域抹掉了,模型学不到食材证据,只能靠背景、餐具、桌面纹理来凑判别信号。训练时这套信号在验证集上有效,真换环境就失效。

解决:把擦除面积上限调低,比如不超过原图的 15%,概率降到 0.5 以下。更重要的是做增强样本可视化:把增强后的图打印一批,如果人眼已经认不出是什么菜,说明增强强度越界了。用类激活映射回看训练后的模型,如果高亮区域集中在盘子边缘而不是菜本体,回到数据增强这一步重新调。

5.4 解冻全层用大学习率,预训练特征被冲掉:灾难性遗忘

现象:预训练模型加载后直接全量解冻、学习率设成 1e-3,前几个 epoch loss 不降反升,之后走向过拟合,精度还赶不上只训分类头的版本。

原因:随机初始化的分类头在早期产生的大梯度,在解冻全层时直接回传到了整个主干,预训练积累的通用特征被快速覆盖。这就是迁移学习里的灾难性遗忘,一旦发生,只能重载权重再来,等于白训一轮。

解决:严格按 4.2 节的顺序走——先冻结主干训分类头,稳定后再从高层往低层逐层解冻。解冻部分的学习率从 1e-4 起步,同时加 3~5 个 epoch 的 warmup。训练初期如果 loss 冲高,第一步不是调学习率,而是检查是不是解冻得太早、层次太多。

5.5 训练一版能 work,部署换成量化推理后错乱:预处理不一致

现象:训练和验证表现正常,导出模型后,在推理端同一张图输出完全错乱,概率分布明显不对,看起来又不是代码崩溃。

原因:部署端的前处理没有完全复刻训练端。常见的有三个:一是通道顺序,训练用 RGB,推理端读图默认成了 BGR;二是归一化参数不一致,训练用的均值方差和推理端对不上;三是缩放方式,训练用 resize 拉伸到 224×224,推理端用 letterbox 补边,图像内容分布就变了。

解决:把预处理封装成唯一一份函数,训练、验证、推理都调用它,不要各自写一套。模型导出后拿同一张测试图,打印训练端和推理端的输出向量做对比,数值差异应该接近 0。如果差异明显,逐个检查通道顺序、归一化参数、缩放方式三项变量。这个检查在量化推理时尤其重要,量化本身会把数值扰动放大,预处理再不一致,误差根本查不清来源。

6. 设计取舍:模型容量、两阶段结构和评估口径之间的那杆秤

食物识别项目做到后期,瓶颈通常不在单个模型的准确率,而在几个绕不开的设计取舍上。第一个取舍是“整图直接分类”还是“目标检测 + 裁剪再分类”。整图分类简单,一个 CNN 端到端出概率,但如果真实场景是“一桌多菜”,背景里其他菜会干扰判断;两阶段先定位盘子再分类,精度更高,但要多维护一个检测器和一整套调度逻辑。取舍标准应该看部署场景:一人一菜一桌,整图分类够了;食堂、家庭聚餐这类多物共存的场景,检测前置的价值更大。

第二个取舍是模型容量和数据量之间的平衡。大模型在数据量不够时学的是捷径而不是食材特征,泛化更差;小模型容量有限,反而被迫抓住更本质的判别线索。我见过不少项目用 ResNet-50 级模型在几千张图上过拟合到 70%,换轻量模型加数据增强反而涨到 85%。先评估手里有多少真实样本,再去选模型,顺序不能反。

第三个取舍是量化精度。int8 量化能把模型体积压到四分之一,算力要求也大幅降低,但食物识别对颜色敏感,浅层激活压缩后“红亮酱汁”和“深红汤汁”的边界容易糊掉。如果量化后准确率掉得厉害,可以在校准阶段把前几层保留为 float16,只量化深层特征,这是部署精度和速度之间常见的折中。

验证方法上,我每次必做两件事:混淆矩阵看哪两类菜最常互相认错,把错误样本单独抽出来回看;再用类激活映射检查模型的注意力落在哪里。这两步做完,下一步是加数据、改损失还是换结构,都有了明确依据。

换做现在的我,拿到任何一份食物识别项目,都会先问三个问题:数据是怎么划分的,评估用的是宏平均还是整体准确率,部署端预处理和训练端是否完全一致。从那以后,我每次跑实验都强制走一遍混淆矩阵和 hard example 回看,再决定下一步动模型还是动数据。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进