简介:一份基于卷积神经网络的食物图像识别项目压缩包,面向Python/TensorFlow初学者和图像识别方向学习者,覆盖从数据集准备、模型搭建到训练评估的完整链路。项目围绕CNN典型结构展开,涉及卷积层、池化层、全连接层及ReLU激活函数,并包含图片尺寸调整、像素归一化、数据增强等预处理思路,可直接作为课程设计或入门深度学习的实战参考。压缩包共32个文件,大小约17.28MB,以12个Python脚本为主体,分别承担模型定义、图片变换、训练、测试和结果输出等功能,另配有tfrecords格式样本数据、Markdown说明文档以及若干配置文件,解压后便于运行和复现实验。已有6643人学习下载。通过该压缩包,读者可以获得一份可运行的食物图片分类实现,结合MNIST等示例数据与注释清晰的代码,理解CNN如何从输入图像逐步提取特征并输出类别概率;还可参考迁移学习思路,借助VGG、ResNet等预训练模型微调识别精度,进一步掌握深度图像分类项目的组织方式。
1. 基于卷积神经网络的食物图像识别:从“拍一道菜”到“认出这道菜”
手机对准一盘菜,程序要判断它是“宫保鸡丁”还是“辣子鸡”——这件事的难点从来不在调用API,而在于两张照片可能长得极像。基于卷积神经网络的食物图像识别,本质上是用卷积层自动从像素里提取颜色、纹理和局部形状特征,代替手工设计的颜色直方图与SIFT特征。红烧肉和回锅肉之间的类间相似、同一道菜在不同餐厅摆盘千差万别的类内差异,这两座山恰好是卷积的局部连接和池化的平移不变性擅长翻越的。这篇笔记适合两类人:刚接触图像识别的学生,以及想用现成数据快速验证菜品分类原型的工程师。我会按数据准备、模型搭建、训练调参、踩坑排查的顺序,讲完一套能在本地跑通并继续优化的最小方案。
2. 卷积与池化怎样看一道菜:纹理特征、摆放位置与选型直觉
2.1 卷积核在做什么:边缘、纹理与颜色特征
卷积层的核心操作并不复杂:一个小尺寸的卷积核(通常是 3x3 或 5x5)在输入图像上按步长滑动,对局部区域的像素做加权求和,再加偏置、过激活函数。关键不在于这个数学运算本身,而在于“一组卷积核扫描整张图”这件事带来的两个性质:局部连接与参数共享。局部连接意味着每个神经元只看图像的一小块,而不是全图;参数共享意味着同一个边缘检测器可以在画面所有位置复用。对食物图像来说,这非常划算——你不需要为“盘子左上角”和“盘子右下角”分别学习一个边缘检测器,一组够用的核就够了。
真正值得琢磨的是卷积层的堆叠效果。第一层卷积通常学到的是基础视觉单元:盘子的圆弧边缘、辣椒的红色色块、汤汁的反光。第二层卷积把边缘组合成纹理,比如米饭的颗粒感、肉块的纤维走向。到了第三层往后,特征图才开始接近“部位”或“部件”的语义,比如虾仁的轮廓、菜叶的形态。这个从低级到高级的过程不是人为设计的,而是反向传播从数据里自动学出来的。这也是为什么食物图像识别很少再有人手工提取特征——CNN 把特征工程变成了特征学习。
食物图像有一个容易被忽略的特性:颜色信息极其关键。青椒肉丝和芫爆肉丝,纹理可能接近,但颜色分布有明显差异。卷积核在 RGB 三个通道上分别计算再相加,天然就能利用这种颜色差异。相比之下,如果先把图像转成灰度再送入网络,等于主动扔掉了一大半判别信息。我见过有人为了“减少计算量”把食物图灰度化,结果验证集准确率直接掉了十几个点。处理食物图像,颜色通道必须保留。
2.2 池化为什么能容忍“摆盘位置变了”
池化层是 CNN 里看起来最“笨”的操作:最大池化就是在 2x2 的窗口里取最大值,平均池化就是取平均值,然后步长 2 滑动,把特征图的尺寸减半。但这个“笨”操作给了网络一个非常重要的能力——对位置微小的变化不敏感。一盘菜里的肉块往左挪了两个像素,经过最大池化后,激活强度基本不变,变的只是它在特征图里的精确坐标被丢弃了。这个特性在食物识别里比在一般物体识别里还重要:没有哪家餐厅会按同一个模板摆盘。
池化还有一个常被新手忽略的效果:扩大感受野。224x224 的输入经过三次 2x2 池化后,特征图变成 28x28,此时特征图上每一个点对应的原始区域大约是 8x8 的局部范围。后面的卷积层虽然还是 3x3 核,但“看”到的已经是原始图像上更大一块区域。这就能解释为什么深层卷积能判断“这是汤”而不只看得到汤里的肉块——它已经把汤汁、容器、表面油花这些上下文信息组合进了同一个特征点。没有池化,网络就只能一直在原始分辨率上做局部判断,很难形成全局语义。
需要注意的是,池化不是万能的。它只能容忍平移和小幅形变,不能容忍大幅旋转。一盘菜转了 90 度,对池化来说仍然差异巨大。所以实际做法往往是对训练集做随机的水平翻转和旋转增强,让网络被动学会旋转不变性,而不是指望池化自己扛住。
2.3 选型直觉:自建 CNN 还是迁移学习
动手之前先定路线。常见做法有两种:从零搭一个小型 CNN,或者用 ImageNet 预训练模型做迁移学习。我把它们放在同一个表格里对比,方便你根据数据量和场景做选择。
| 对比维度 | 自建小型 CNN | 迁移学习(ResNet50 等) |
|---|---|---|
| 需要的数据量 | 数千张可勉强起步 | 每类 50~100 张就能看到效果 |
| 训练成本 | 普通 CPU 也可跑,GPU 更快 | 建议 GPU,冻结骨干网络时 CPU 也勉强可行 |
| 调参难度 | 结构、正则化都要自己试 | 主要调全连接头和优化器,难度低 |
| 准确率上限 | 类别少时够用,类别多后明显吃力 | 通常高出一截,尤其是 Fine-tune 之后 |
| 适合场景 | 原型验证、学习 CNN 原理 | 真实项目、类别多、数据来源杂 |
我的习惯是:如果只是验证“这个数据集能不能分得开”,先自建一个小 CNN,把训练流程跑通,看 val_acc 能到多少。如果发现准确率卡在某个水平上不去,再切迁移学习。这样每一步的变量都很少,出了问题容易定位。直接上来就用 ResNet50,确实准确率高,但一旦效果不好,你很难判断是数据问题、预处理问题还是微调参数问题。
3. 准备食物图像数据集:目录结构、预处理参数与数据增强
3.1 数据集选型与目录整理:文件怎么摆决定后面少踩坑
食物图像识别最常用的公开数据集是 Food-101,包含 101 个类别、每个类别 1000 张图,图片来源真实,背景杂乱,很适合用来验证模型。但我不建议第一次跑就全量训练,更稳的做法是从里面挑 10 个容易混淆的类别(比如不同做法的鸡肉、不同做法的猪肉),先做一个 10 分类原型。类别越少,迭代越快,也越容易看清预处理和模型结构里的问题。确认流程没问题后,再扩展到全量 101 类。
无论用 Food-101 还是自建数据,目录结构都要遵循一个约定:按类别建文件夹,图片直接放在类别目录下。这样torchvision.datasets.ImageFolder可以按目录名自动生成标签,不需要额外写标签映射文件。
data/ ├── train/ │ ├── fried_chicken/ │ │ ├── 001.jpg │ │ └── 002.jpg │ └── kung_pao_chicken/ │ ├── 001.jpg │ └── 002.jpg └── val/ ├── fried_chicken/ └── kung_pao_chicken/如果你是从网上下载图片做自建数据集,训练集和验证集要提前按类别比例切好,不要等训练时再用random_split切分。原因很现实:同一道菜的图片可能来自同一个视频帧或同一个餐厅的连拍,random_split会把高度相似的图片同时分进训练集和验证集,导致 val_acc 虚高,部署到真实场景立刻现原形。按目录结构手动拆分,至少能控制这个风险。
3.2 预处理三件套:resize、归一化与数据增强
预处理是食物图像识别里最容易被低估的环节。我见过不止一次,模型结构没问题,准确率却上不去,最后发现是预处理和数据集默认格式不匹配。正常的步骤是三步:统一尺寸、归一化、数据增强。先用 PyTorch 的transforms把流程写好:
import torch from torchvision import datasets, transforms # 训练集:随机裁剪到 224x224,加水平翻转和颜色抖动 train_transforms = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证集:只做放缩、中心裁剪和归一化,不做随机增强 val_transforms = 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]) ])逻辑说明:RandomResizedCrop会随机选一个区域并缩放到 224x224,scale=(0.7, 1.0)表示裁剪区域占原图面积的 70% 到 100%,刻意不裁太狠,避免把食物主体切掉。ColorJitter的亮度、对比度、饱和度抖动值设在 0.2 是保守选择,因为同一道菜在不同餐厅的灯光色温差异很大,小幅抖动能让模型不那么依赖特定色温。验证集不用随机增强,Resize(256)再CenterCrop(224)是通用做法,保证评估时的输入是确定性的。
参数说明:Normalize里的 mean 和 std 用的是 ImageNet 的统计值,这是迁移学习的默认选择;如果是自建 CNN 从头训练,也可以改成按你自己的数据集统计。另一个容易忽略的点是ToTensor必须放在归一化之前,因为它会把 PIL 图像从 HWC 转成 CHW 并把像素缩放到 [0,1],归一化才能在正确的数值范围上做。
3.3 训练集/验证集划分:代码与参数怎么定
目录结构准备好之后,加载数据就很简单了。关键是把DataLoader的参数设对:训练集要 shuffle,验证集不要;batch size 要看显存;num_workers在 Windows 上如果报错要改成 0。
train_dataset = datasets.ImageFolder("data/train", transform=train_transforms) val_dataset = datasets.ImageFolder("data/val", transform=val_transforms) train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True ) val_loader = torch.utils.data.DataLoader( val_dataset, batch_size=64, shuffle=False, num_workers=4, pin_memory=True ) print("训练类别:", train_dataset.classes) print("训练样本数:", len(train_dataset)) print("验证样本数:", len(val_dataset))逻辑说明:ImageFolder会自动扫描data/train下的子目录,按字母序生成类别索引,train_dataset.classes里能看到类别和索引的对应关系。shuffle=True在每个 epoch 开始前打乱数据,避免模型按文件夹里的排列顺序学到虚假规律。验证集保持shuffle=False,方便之后逐批核对预测结果。
参数说明:batch size 64 是一个中等偏保守的起点,如果显存不足(比如 GTX 1060 跑 224x224 输入),就降到 32。pin_memory=True在 GPU 训练时能减少数据传输时间,但纯 CPU 训练时没有意义。num_workers=4表示用 4 个子进程预取数据,这个值不是越大越好,Windows 下多进程有时会触发奇怪的报错,遇到就直接设 0。
4. 用 PyTorch 训练 CNN 食物分类器:模型定义、训练循环与调参
4.1 一个能跑的小模型:卷积层、池化层与全连接层的参数配置
从零搭建 CNN 时,我的原则是“先让模型在验证集上动起来,再谈优化”。一个三层卷积的小模型通常就够验证数据是否可分。下面是完整的模型定义:
import torch.nn as nn class FoodCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( # 输入 3x224x224,输出 32x112x112 nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 输入 32x112x112,输出 64x56x56 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 输入 64x56x56,输出 128x28x28 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(128, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))逻辑说明:每一层卷积都保持padding=1,目的是让卷积不改变特征图的宽高,尺寸变化只由MaxPool2d(2)负责。224x224 的输入经过三次池化变成 28x28,通道数从 3 逐步加到 128。BatchNorm2d放在卷积和 ReLU 之间,能稳定训练过程,特别是在小 batch size 下效果明显。AdaptiveAvgPool2d(1)是一个很实用的设计:不管输入尺寸是多少,它都输出 1x1 的特征图,这样全连接层的输入维度永远是 128,换输入分辨率时不用改模型结构。
参数说明:通道数 32→64→128 是一个很稳的增长曲线,每层翻倍能在控制参数量的同时保留足够信息。Dropout(0.5)在全连接层之前使用,训练时随机丢弃一半神经元,能缓解过拟合。这个模型的参数量大约在 120 万左右,CPU 上跑一个 epoch 也不至于慢到没法等。
4.2 训练主循环:loss、优化器与学习率
模型定义好了,接下来是最容易出乱子的部分:训练循环。新手最常见的错误是把model.train()和model.eval()写反,或者忘了切换,导致 BatchNorm 的行为在训练和推理时不一致。训练循环的完整代码如下:
import torch import torch.optim as optim model = FoodCNN(num_classes=10) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) num_epochs = 30 for epoch in range(num_epochs): # 训练阶段 model.train() total_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() # 验证阶段 model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = 100.0 * correct / total avg_loss = total_loss / len(train_loader) print(f"epoch {epoch+1:02d} | loss {avg_loss:.4f} | val_acc {val_acc:.2f}%") scheduler.step()逻辑说明:每个 epoch 分两段。训练段用model.train()打开 Dropout 和 BatchNorm 的训练模式,每批数据前先optimizer.zero_grad()清空梯度,否则梯度会跨 batch 累加。验证段用model.eval()切换到推理模式,再包上torch.no_grad()告诉 PyTorch 不需要构建计算图,省显存也省时间。torch.max(outputs, 1)取每个样本预测概率最大的类别索引,和真实标签做比较。
参数说明:Adam 搭配 lr=1e-3 是小模型最省心的起点,不需要手动调整动量。CrossEntropyLoss内部已经包含 softmax,模型最后一层不要再接 softmax。StepLR(step_size=10, gamma=0.5)表示每 10 个 epoch 学习率减半,这种方式简单有效,比 CosineAnnealing 更容易理解。30 个 epoch 对小数据集够用了,如果到第 20 个 epoch 验证集准确率还在涨,可以手动续跑,不一定非要一开始就把 epoch 设很大。
4.3 训练曲线怎么读:train loss 和 val accuracy 分开看
训练跑起来之后,真正的挑战是看懂曲线、判断下一步怎么办。训练曲线这东西有点玄学,但也有一套固定的规律可以参照。我一般会同时盯 train loss 和 val_acc 两个指标,它们反映的问题完全不一样。
train loss 只降不升但降得很慢,多半是学习率太小,或者模型容量不够。前者把 lr 从 1e-3 提到 3e-3 试试,后者就要加卷积层通道数或加深网络。train loss 直接不降,先查预处理:是不是归一化写错了?是不是ToTensor和Normalize顺序反了?这两类问题的比例远高于模型结构问题。
val_acc 比 train loss 更能说明问题。如果 train loss 持续下降、val_acc 却停滞甚至下跌,这是经典的过拟合信号。应对手段有四个,按优先级排:增大数据增强强度、增加 Dropout 比例、缩小模型、提前停止训练。如果 train loss 和 val_acc 同时上不去,那大概率不是模型的问题,而是数据本身有问题——类别不均衡、标注错误、图片分辨率过低,这些在第 5 章里展开讲。
val_acc 上下剧烈震荡也是食物数据常见的现象,尤其当训练集里某几个类别样本特别少时。不要急着调结构,先看是不是采样造成的小批量噪声,把 batch size 从 64 减到 32,或者换用带权重的采样器,震荡通常会缓解。
5. 食物图像识别翻车排查:五个常见坑与修复办法
5.1 训练时随机裁剪,推理时直接缩放,准确率对半砍
现象:训练集上用RandomResizedCrop(224)做增强,验证集上为了省事只写了Resize(224),结果训练了 20 个 epoch,验证集准确率只有 40%,比随机猜(10%)高不了多少。
原因:Resize(224)会把整张图直接压成 224x224,图像被非等比拉伸,食物形态严重变形;而训练时网络看到的是从原图裁剪出来的方形区域,两者分布完全不同。模型在推理时面对的是它从未见过的图像形态,自然表现极差。
解决:验证集(和推理阶段)必须与训练集保持一致的预处理哲学。常见做法是Resize(256)后CenterCrop(224),保留等比缩放的同时裁出中心区域。更稳的做法是训练和推理完全使用同一套 transform,只在是否启用随机增强上做区分。
5.2 数据增强过猛,验证集跟着遭殃
现象:为了提升泛化,把ColorJitter的 brightness、contrast、saturation 都调到 0.5,结果 train loss 降不下来,val_acc 也不涨。
原因:增强强度过大时,训练样本的颜色分布已经偏离真实食物图像太远。比如亮度抖动 0.5,会让本来暗红色的红烧肉变成亮橙红色,模型学到的颜色特征与真实场景不匹配。食物识别的颜色信号极强,过度破坏颜色会直接丢失判别信息。
解决:增强强度先保守,0.1~0.2起步,观察 val_acc 是否随 epoch 上升。如果模型在验证集上出现过拟合迹象,再逐步加大增强强度,而不是一开始就拉到最高。记住一个原则:增强的目的是让模型看不到完全一样的两张训练图,不是把训练图变成另一道菜。
5.3 类别不均衡:模型学成了“米饭检测器”
现象:自建数据集中“米饭”有 2000 张,“拔丝苹果”只有 150 张。训练结束后,模型对所有图片都倾向于预测“米饭”,整体准确率看起来还行,但每个类别的召回率差距悬殊。
原因:CrossEntropyLoss默认对每个样本一视同仁,多数类样本多,梯度贡献大,模型自然偏向多数类。这是分类任务里最常见的隐性问题,整体 val_acc 会掩盖它。
解决:先用混淆矩阵看每个类别的 recall,而不是只看整体准确率。如果确认类别不均衡,用WeightedRandomSampler给少数类更高的采样概率,或者给CrossEntropyLoss传weight参数,让少数类的损失权重更高。我一般先试weight参数,改动最小,效果不够再上采样器。
5.4 BatchNorm 在推理时的隐性要求:单张图预测结果不稳定
现象:验证集上 val_acc 95%,但拿一张手机实拍图做单张推理时,结果在两次运行之间偶尔不一样,或者在 batch 推理和单张推理之间不一样。
原因:模型用了 BatchNorm,训练时统计的是每个 batch 内的均值和方差,推理时用的是训练阶段累积的 running mean 和 running variance。如果推理前忘记调用model.eval(),BatchNorm 会继续按当前 batch 统计,单张图的均值和方差完全由这一张图决定,结果当然不稳定。
解决:推理前必须调用model.eval(),这是最基本的正确操作。如果确认已经调用但单张推理仍然波动,检查输入图像的预处理是否和训练时完全一致,尤其是缩放尺寸和归一化参数。另一个常见误用是在model.eval()之前展开predict函数,结果部分代码在训练模式下执行。把model.eval()和推理循环放在同一个作用域里,别分散写。
5.5 彩色通道顺序反转:颜色特征全乱了
现象:模型训练时准确率正常,但部署到某个服务里后,对所有菜的预测结果都明显偏向某一类,或者干脆变成随机猜测。
原因:训练时用 PIL 读图,默认是 RGB 顺序;部署时用了 OpenCV 的cv2.imread,它读出来的是 BGR 顺序。两种顺序喂进同一个模型,卷积核学到的是 RGB 颜色分布,推理时输入却是 BGR,颜色特征完全错位。食物识别的颜色信号强,这个坑的破坏力也特别大。
解决:在项目一开始就约定图像的读取方式,统一用 PIL 或统一用 OpenCV。如果已经用 OpenCV 读了图,用cv2.cvtColor(img, cv2.COLOR_BGR2RGB)转回 RGB 再送入模型。排查这个问题最快的办法是取一张训练图像,用部署环境的读图代码读进来,打印像素均值,和训练数据的均值对比一下,差异很大就基本实锤了。
6. 用迁移学习把准确率拉高 10 个点,并做好本地部署验证
6.1 用 ResNet50 替换自建 CNN,冻结骨干网络只训练分类头
如果自建 CNN 在 10 分类上能做到 80% 以上,但你想继续往上走,最快的方法不是加层,而是换用预训练模型做迁移学习。ImageNet 预训练的 ResNet50 已经学会了丰富的边缘、纹理和形状特征,食物图像里的很多视觉模式它是见过的。代码改动很小:
import torchvision.models as models # 加载 ImageNet 预训练权重,替换最后一层全连接 model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) model.fc = nn.Linear(model.fc.in_features, num_classes) # 冻结骨干网络,只训练新加的分类头 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True逻辑说明:IMAGENET1K_V2是当前可用的预训练权重标识,加载后模型的fc层是输出 1000 类的,需要替换成自己的分类头。冻结骨干网络的目的是让预训练特征在初期不被破坏,只让分类头学习怎么组合这些特征。这样训练很快,几个 epoch 就能看到效果。
我自己的习惯是先在冻结状态下跑 5~10 个 epoch,把学习率设为 1e-3,观察 val_acc 的上限。如果还在涨,就解冻骨干网络最后两三个残差块,把学习率降到 1e-4 继续微调。注意解冻后不能再用原来的 1e-3,否则预训练权重会被大幅扰动,前几个 epoch 的 val_acc 反而可能下跌。
6.2 导出模型后的本地验证:用真实场景照片测,而不是用数据集照片
训练时准确率高,不代表上线后表现好。食物识别最容易出问题的地方是部署环境的输入和训练分布不一致。我现在的固定流程是:模型训练完成后,先导出成脚本化模型,再拿手机实拍的五张照片做本地推理验证。
import torch # 导出为 TorchScript,方便脱离训练代码单独部署 model.eval() example_input = torch.randn(1, 3, 224, 224) traced_model = torch.jit.trace(model, example_input) traced_model.save("food_cnn.pt")验证时我会刻意选三种困难场景:强光下拍的菜、两道菜同框的照片、已经吃到一半的菜品。这三种场景是数据集里很少出现的,如果模型在这些图上的预测结果和真实标签明显不符,说明数据增强或预处理还有缺口,这时返回去补训练数据比继续调模型结构更有效。
做食物图像识别这两年,我最深的体会是:模型结构只决定准确率的下限,数据质量和预处理的一致性才决定上限。验证集刷到 95% 并不难,难的是部署后接住同事随手拍的一张外卖照片。现在我交付任何一版模型前,都会要求自己先用五张真实场景照片跑一遍推理,翻过这个坎再谈上线。希望这篇基于卷积神经网络的食物图像识别的实战笔记,能帮你少走几步弯路——从目录结构到训练调参,每一步都有章可循,剩下的就是耐心和迭代。
本文还有配套的精品资源,点击获取