简介:针对食物图像识别场景,这套资源提供了一份可运行的卷积神经网络项目,采用Python与TensorFlow实现,适合想从零搭建图像分类模型的深度学习者。压缩包共32个文件,以12个Python脚本为主体,分别负责数据预处理、模型结构定义、训练、测试与结果输出,同时包含TFRecord数据文件、PyCharm工程配置、版本忽略与说明文档等,整体约17.28MB,文件分类明确,适合对照代码逐步理解CNN的模块划分。目前已有6643人学习下载。运行这套代码,可以亲自观察卷积层提取特征、池化层压缩维度、全连接层输出类别概率的过程,并尝试调整优化器或加入数据增强来改善泛化。项目还保留了训练日志与模型权重,便于复盘和断点继续训练;若想进一步提升准确率,也可参考迁移学习思路,在现有网络基础上微调。通过完成这个实战项目,读者既能熟悉tf.keras的核心用法,也能掌握从图像读取到类别预测的完整流程。
1. 食物图像识别为什么非要用 CNN:从一道家常菜引发的误判说起
有一次我给朋友做菜谱 App 的演示,端着一盘“鱼香肉丝”在双倍光照下拍照,模型的预测结果竟然是“糖醋里脊”。问题不在菜名,而在于图片里盘子反光、配料比例和训练集里的照片差异太大。这个场景把图像识别里最典型的问题暴露了出来:用颜色直方图、边缘检测这类手工特征很难同时容忍菜品的形变、光照变化和摆盘差异。卷积神经网络(CNN)正好是干这个的,它不是把整张图压成全局特征,而是用局部卷积核逐层提取边缘、纹理、食材团块和整体布局,天然适合食物这类“结构强、颜色敏感、背景混乱”的对象。
如果你正在做菜品自动识别、食堂结算台、食材库存拍照盘点,或者想给饮食记录 App 加一个识别入口,这篇文章就是你从零起步的一版实操路径。我会按照“先备数据、再搭模型、后调训练、避开常见坑”的顺序,把每一步能抄的代码和不能省掉的参数讲清楚。
2. 给 CNN 建输入:食品数据集选择与图像预处理
2.1 数据集选型:Food-101 和自建数据集怎么取舍
先说数据集。公开的 Food-101 是绕不开的基准:101 类、每类 1000 张,共 101000 张图,类别覆盖披萨、沙拉、寿司、牛排等视觉差异较大的菜。但要注意它的每张图都来自网络搜索,图片风格杂乱,很多图里除了菜还有桌面、手、饮品,这反而适合做通用模型的起点。如果只做中式菜品,Food-101 里几乎没有对口类别,就需要用爬虫或者从外卖平台合作方拿数据自建。自建数据集的规模可以小,但至少要保证每个菜品类别下覆盖 10 个以上不同来源(不同门店、不同拍摄设备),防止模型记住特定的容器和摆盘。
怎么判断数据够不够?我一般会给模型先跑一个简单的 LeNet 探路,观察训练集 loss 能不能压到很低。如果训练集 loss 下不去,说明图像与标签之间有大量噪声或者没对齐;如果能下得去但验证集 loss 差得远,才考虑增加数据量。还有一类容易忽略的问题是类别不平衡:比如“白米饭”样本数远大于“锅巴饭”,分类器会偏袒大头。这时可以用WeightedRandomSampler做采样加权。
| 数据集 | 类别数 | 单类样本 | 适合场景 |
|---|---|---|---|
| Food-101 | 101 | 1000 | 迁移学习、通用效果验证 |
| Vireo Food-172 | 172 | 约5000 | 细粒度识别,类别更细致 |
| 自建 | 自定义 | 建议≥300/类 | 特定餐厅、食堂、地域菜品 |
在做这里之前,我还要解释一个经常被搜到的点:网上很多一维卷积神经网络介绍的文献,讲的是心电图、语音这类一维信号,卷积核在时间轴上滑动;但食物照片是二维矩阵,卷积核要在宽度和高度两个方向滑动。如果你是从 NLP 转过来看图像,别把一维 CNN 的实现逻辑硬搬到图像上,二维卷积核的感受野和步长设计完全不同。
自建数据集还需要注意“标签标准化”。我见过一个项目里“西红柿炒鸡蛋”和“番茄炒蛋”同时存在,这两类在视觉上几乎没有稳定区分度,导致模型 top-1 永远在这两个类之间跳。宁可把标签合并成“番茄炒蛋”,也不要为犟嘴保持双标签。类目设计要满足互斥性:一张图只能属于一个类,如果一张餐盘里有三个菜,那它就要被排除或者单独建一个“套餐”类,别硬塞进某一个单独菜品。
2.2 预处理:resize、归一化与增强的工程取舍
CNN 输入尺寸越接近训练集分布越好。迁移学习时,一般沿用预训练模型的输入尺寸:ResNet 系常用 224x224,EfficientNet 可能是 240 或 260。不要凭想法改成 320x320,除非你有足够算力和更多训练数据。放大输入会把 pretrain 里见过的空间尺度改变,导致模型需要重新适应。
最常见预处理组合是:读取图片 → 最短边缩放 → 中心裁剪或随机裁剪 → 转 Tensor → 按 ImageNet 的 mean/std 归一化。ImageNet 的均值是[0.485, 0.456, 0.406],标准差是[0.229, 0.224, 0.225]。为什么要用这一组?因为预训练模型是在这个分布上收敛的,输入数据保持同一分布才能让冻结或微调的权重继续生效。如果你自己从零训练,可以不遵循这套均值,但除非必要,别改。
食物图像有一个很具体的坑:颜色是强特征,尤其对于红烧肉、宫保鸡丁这种强色型菜品,亮度扰动对结果影响很大。所以做数据增强时,随机亮度/对比度调整的幅度不要像 ImageNet 分类那么狠。我一般用ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05),hue 扰动尤其要小,否则肉的颜色会失真到像另一个菜。另外一个增强是随机擦除或 cutout,可以模拟被勺子遮挡、手挡住一部分的场景,这个对食堂结算台那种真实遮挡很有帮助。
不要为了加快提速先把图片转灰度。食物识别中最具判别力的信息之一就是食材颜色,新鲜度、成熟度、烹饪方式都能体现在色彩上。如果为了追求轻量模型而丢掉颜色,等于自废武功。一个折中方案是训练 3 通道输入,部署时用cv2直接按原图读取,不要先转 JPEG 再读,因为重复压缩会改变颜色分布。
2.3 用 PyTorch 写一个食物数据管道
下面这个Dataset类是我在项目里用的简化版,处理训练和验证两套不同的 transform。
import torch from torch.utils.data import Dataset, DataLoader from PIL import Image import torchvision.transforms as T class FoodDataset(Dataset): def __init__(self, image_paths, labels, train=True): self.image_paths = image_paths self.labels = labels if train: self.transforms = T.Compose([ T.RandomResizedCrop(224, scale=(0.7, 1.0)), T.RandomHorizontalFlip(), T.RandomRotation(15), 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]) ]) else: self.transforms = T.Compose([ T.Resize(256), T.CenterCrop(224), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img = Image.open(self.image_paths[idx]).convert('RGB') label = self.labels[idx] return self.transforms(img), labelRandomResizedCrop的scale参数控制裁剪面积占原图的比例,0.7~1.0 比默认的 0.08~1.0 温和,适合食材主体一般占据画面中心的情形。验证集用Resize(256) + CenterCrop(224)是为了让图片每个区域都可控,不引入随机性,保证指标稳定。注意Image.open后一定要.convert('RGB'),很多手机拍摄的 EXIF 可能是灰度模式,不转会导致张量维度不匹配。
DataLoader 的配置也很关键:
train_loader = DataLoader( train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True, drop_last=True )drop_last=True在最后一个 batch 不足时丢弃,这能让 BN 层的统计更稳定;num_workers=4在 Windows 上常常要设成 0,否则多进程会反复启动报错;pin_memory=True适合 GPU 训练,能减少 CPU 到 GPU 的传输时间。shuffle必须为 True,否则每个 epoch 看到同一批顺序,模型容易记住样本的位置,而不是学到菜品特征。
3. 搭建 CNN 图像分类器:从迁移学习到训练循环
3.1 为什么我一上来就用迁移学习,而不是从头训练
CNN 的本质是在学习特征层级:底层卷积核识别边缘、颜色块和纹理,高层卷积核响应食物的结构。食物图像识别的瓶颈往往不在基础特征,而在高层语义,比如不同菜品有着完全相同的边缘纹理但整体布局不同。因此,直接复用 ImageNet 预训练权重,把最后全连接层换成你的菜品类别,是一个非常划算的起点。
可能有人问:ImageNet 里没有“鱼香肉丝”,为什么有用?因为底层特征如边缘、轮廓、食材质感是完全通用的。一张盘中肉丝的局部纹理与 ImageNet 里的“红色材质”高度相似。我用迁移学习在 5 类自建食物数据上能做到 92% 左右,而相同条件下从零训练的 ResNet 18 要调 300 轮才能勉强到 84%。数据越多差距才越小,如果只有几千张食物图,不要从零训练,至少先用预训练权重初始化。cnn 卷积神经网络的常见误区之一是把网络结构当成重点,却不重视权重初始化,这一步省了,后面调参等于白费。
3.2 加载预训练模型的代码
用torchvision加载 ResNet18,这是做小数据量视觉识别最省事的一步:
import torchvision.models as models model = models.resnet18(pretrained=True) num_classes = 12 # 替换最后一层,让输出维度对应食物类别数 in_features = model.fc.in_features model.fc = torch.nn.Linear(in_features, num_classes)pretrained=True会自动下载resnet18-f37072fd.pth权重。第一次运行会需要网络,建议把torch.hub的缓存目录指定到有空间的路径。不要直接改model.fc.out_features,因为旧全连接层的权重矩阵还保留着,改成类数后参数形状不对,运行时会报维度不匹配。
新版 torchvision 推荐用weights=models.ResNet18_Weights.IMAGENET1K_V1替代pretrained=True,两者都能用,但显式写法更清晰。如果你想换成 ResNet50,把类名替换即可,输入尺寸还是 224x224。
3.3 必调的三个超参数:学习率、batch size、权重衰减
迁移学习的核心法则是:冻结部分在前,微调部分在后,学习率一般要小。常用配置:batch size 32 或 64,初始学习率 1e-4 到 3e-4,权重衰减 1e-4。如果只训练新分类头,可以把学习率放到 1e-3;如果对整个网络微调,就按 1e-4 来。为什么不把学习率设大?因为预训练权重的梯度不太希望被一次性冲掉,大学习率很容易让底层学到的纹理特征被噪声覆盖。
优化器一般用 AdamW 或 SGD+momentum。SGD 最终精度往往比 Adam 更稳,但调起来麻烦。数据量在几千张时,我一般直接用 AdamW,初始 lr 1e-4,weight_decay 1e-4。下面是一段可复用的训练配置:
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR criterion = torch.nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6)CosineAnnealingLR把学习率在 30 个 epoch 内按余弦曲线从 1e-4 降到 1e-6。对视觉任务来说,开始阶段学习率不宜衰减太快,后段低学习率有助于收敛到平滑点。如果你用 SGD,别忘了加momentum=0.9。eta_min不要设成 0,否则最后模型几乎不更新,loss 会长时间停滞。
3.4 训练循环和 checkpoint 保存
下面这段代码涵盖冻结骨干、只训练分类头的场景。食物项目一般先冻结骨干跑 10 个 epoch,再解冻骨干接着跑,这样可以避免随机初始化的分类头的大梯度把预训练权重冲坏。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 correct = 0.0 total = 0.0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() logits = model(imgs) loss = criterion(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) preds = logits.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total注意在训练模式下必须调用model.train(),它会开启 BN 层统计和 Dropout。验证时则要model.eval()并包裹torch.no_grad(),否则 BN 在单张图上表现异常,验证指标会有跳动。食物识别经常会遇到“训练集很稳、验证集反复横跳”的情况,多半是 batch size 太小导致 BN 的统计不稳定,或者 shuffle 忘了开。
checkpoint 保存,只保存模型权重而不是整个模型:
torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'best_acc': best_acc, }, 'checkpoint_food.pth')恢复训练时用torch.load再load_state_dict。如果从 GPU 训练到 CPU 机器加载,必须加map_location='cpu',否则会报 Unexpected key。这属于另一个高频踩坑,第 5 章会专门写。
4. 训练与验证:用 loss、混淆矩阵定位翻车点
4.1 数据集切分:别把同一道菜同时放进训练和验证
你想训练一个“食堂菜品识别”模型,从 20 个打菜窗口采集照片。常见错误是用随机切分,同一个菜同一个批次可能落在训练集和验证集里,模型只是在“认背景”而不是“认菜”。正确做法是按食物类别分层采样,还要尽量把相近来源分到同一侧。例如按门店分组,同一门店的照片不能同时出现在训练和验证集里,否则模型学会的是门店灯光、餐盘和背景的偏差。
下面是用 sklearn 做分层切分的示例:
from sklearn.model_selection import train_test_split train_paths, val_paths, train_labels, val_labels = train_test_split( paths, labels, test_size=0.2, stratify=labels, random_state=42 )stratify=labels让训练集和验证集保持相同菜品比例。如果你的数据采集自多个批次,最好在传入train_test_split前先按“来源 ID”聚合,比如每个拍摄批次作为一个单元,否则模型会学到背景。常见做法是构建一个group_map,用GroupShuffleSplit切分。
测试集要单独留,不要用它调参。我习惯把原始数据分成 70% 训练、15% 验证、15% 测试。验证集每天都会被反复使用来调整超参数,模型会间接地“记住”验证集;测试集只在最终确认效果时跑一次,否则测试集也会被污染。
4.2 训练指标:top-1/top-5 和 loss 曲线怎么看
只记录准确率很容易被大类主导。食品识别中“米饭”和“面包”这种高频类占多数,模型把所有图片都判成米饭,准确率可能也有 50%。因此我习惯同时记录 top-1、top-5,以及每个类别的召回率和精确率。top-5 对菜品类很有实用价值,因为菜品之间差距本身很小,比如“麻婆豆腐”和“家常豆腐”,模型给用户列 5 个候选比只给一个更符合实际食谱应用。
在训练日志里看到 loss 曲线时,重点关注两条曲线的距离:训练 loss 和验证 loss 的偏差越来越大,是过拟合;两者同时高位不降,是学习率太小或数据噪声;训练 loss 快速下降但验证 loss 不降,是数据分布不一致。一个经验值:训练 loss 降到 0.2 以下而验证 loss 大于 1.0,基本可以断定模型开始记忆训练样本了。
4.3 用混淆矩阵找出最容易搞混的菜
混淆矩阵是“质检报告”。导出验证集预测结果,调用 sklearn 生成矩阵:
from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt cm = confusion_matrix(val_labels, preds, labels=class_names) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=class_names, yticklabels=class_names) plt.show()看混淆矩阵时,别只盯着对角线。我一般会找矩阵里非对角线最高的几个位置,比如“番茄炒蛋”被误判成“西红柿鸡蛋汤”,那就要去检查这两类的样本:是不是采集的图像风格很相近,或者标签本身不一致。此时不要急着加数据,先看训练集的原始图,很多时候是标注错误,比如一张图同时包含两种菜,标签只给了其中一种。
4.4 Early Stopping 和数据增强:压制过拟合的实战组合
控制过拟合的优先级:先减少模型容量或增加正则,再考虑加数据。实际做法是盯验证集 loss,连续 5 个 epoch 不降就停。但注意 patience 不要设太大,否则浪费训练时间。我一般用:
class EarlyStopping: def __init__(self, patience=5, min_delta=0.001): self.patience = patience self.min_delta = min_delta self.counter = 0 self.best_score = None self.early_stop = False def __call__(self, val_loss): if self.best_score is None: self.best_score = val_loss elif val_loss > self.best_score - self.min_delta: self.counter += 1 if self.counter >= self.patience: self.early_stop = True else: self.best_score = val_loss self.counter = 0注意这里判断方向:因为 val_loss 越小越好,所以比 best 高时计数。min_delta是正数,用于忽略微小波动。数据增强方面,除了第 2 章里的简单变换,我更推荐 MixUp 和 CutMix。对食物这种强纹理目标,MixUp 可以让模型不只盯住单一颜色特征,而是学会线性插值后的混合特征,这能在一定程度上提高鲁棒性。
5. 避坑清单:食物识别项目里的 5 个常见雷区
5.1 训练 loss 不降反升,最后发现是数据顺序问题
现象:第一个 epoch 训练 loss 在 5 到 8 之间跳,第二个 epoch 还在涨,检查代码没有任何逻辑错误。
原因:DataLoader 没有设置shuffle=True,或者数据集路径洗牌后又喂给同一个 batch;还有可能是CrossEntropyLoss的输入和标签的 device 不一致,关键计算被放到 CPU 上。
解决:先打印一个 batch 的 labels 分布;再确认imgs, labels = batch都.to(device);最后把 DataLoader 里的shuffle=True打开。注意 DataLoader 的generator如果不固定随机种子,每次重启结果也会变,因此复现时设置torch.manual_seed(42)和generator=torch.Generator().manual_seed(42)。
5.2 验证集精度高,线上频繁误判
现象:离线验证准确率 92%,部署到小程序里被用户吐槽认什么都错。
原因:验证集来自训练数据同分布,而用户上传的照片拍摄角度、分辨率、容器、灯光完全不同。食物图像很容易受背景干扰,辣椒油的反光、盘子上的纹理都可能被当成特征。
解决:除了扩充增强,还要专门采集一个“线上盲测集”。我一般从真实反馈里挑 200 张覆盖手机拍摄、不同光线、外卖出餐盒的照片,单独作为一个集合,不在训练时使用。用这个集合跑一次 bad case 分析,如果模型在盲测集上准确率只有 70%,就在训练集里加入同类负样本,并对线上输入做同样的预处理。不要把离线准确率当交付指标。
5.3 模型把“同一道菜的不同摆盘”当成不同类别
现象:火锅、麻辣烫这类几乎没有固定形态的菜,模型给出的 top-1 在几个相似类之间来回跳。
原因:食物图像识别不同于汽车、人脸,同一道菜的类内方差极大。有些类的区分度本身就低,比如清汤面和阳春面,视觉上几乎没差别。
解决:在类别设计层面合并易混类,或把它设计成“粗分类 + 置信度阈值”。我通常会在 Softmax 后加一个阈值,如果 top-1 概率低于 0.6 则返回“未识别”。这比硬跑一个类别更符合实际体验。
5.4 GPU 上推理很快,但服务端整体延迟高
现象:单独测 GPU 推理 10ms,但整套 API 响应 200ms。
原因:图像从 HTTP 读入、解码、resize、归一化都在 CPU 上串行执行,瓶颈在预处理而不是 CNN。还有一个常见情况是每次请求都重新加载模型权重,或者用torch.no_grad()但忘记model.eval()。
解决:把预处理做成批量操作,或使用 ONNX Runtime 的预处理。实际项目里我们常用固定 batch 大小的推理服务,例如把请求排成 batch 再喂给 GPU,吞吐量能提升 3-5 倍。另外,模型加载用单例模式,不要每次请求都 load。
5.5 迁移学习冻结层没冻结好,分类器永远不收敛
现象:只改最后一个全连接层,但整个网络的参数都在更新,训练时显存消耗巨大,验证精度上不去。
原因:model.parameters()返回全网络所有参数,而你可能忘了requires_grad = False。
解决:先冻结,再替换分类头:
for param in model.parameters(): param.requires_grad = False model.fc = torch.nn.Linear(in_features, num_classes) # 只把分类头的参数交给优化器 optimizer = optim.AdamW(model.fc.parameters(), lr=1e-3)如果需要解冻骨干网络,再设置除model.fc以外的层为requires_grad=True,并且重新把参数过滤给优化器。如果你使用torchvision.models新接口,替换model.fc时原权重会丢,这没关系,因为骨干网络已经被冻结,分类头本来就是随机初始化。
6. 进阶验证:用 Grad-CAM 看模型到底在盯哪块食材
6.1 生成 Grad-CAM:对最后一层卷积层做类激活
准确率和 loss 只能告诉你“模型有没有做对”,不能告诉你“模型为什么做对”。Grad-CAM 能显示模型在预测某个类别时,重点关注了图片的哪些区域。对食物识别来说,这比任何技巧都更直接。安装pytorch-grad-cam后,生成热力图只需要几行:
from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image cam = GradCAM(model=model, target_layers=[model.layer4[-1]]) grayscale_cam = cam(input_tensor, target_category=pred_class) heatmap = show_cam_on_image(denormed_img, grayscale_cam, use_rgb=True)target_layers选择最后一个卷积层,因为它的空间分辨率还在,语义信息最强。target_category用预测类别而不是真实标签,这样能看到模型当前决策的依据。如果热力图大面积落在盘子边缘或桌面倒影上,说明模型被背景污染了。
6.2 把 CAM 当作项目验收的一环
我一般在项目结束前,从每一类里随机抽 5 张图,跑一批 CAM 图。如果某类菜的激活区域稳定在食物上,说明这个类学到的特征是可复用的;如果激活区域乱跑,就需要回到数据层处理。我最早部署时只盯着准确率,后来把 CAM 纳入验收流程,才发现好几个类的激活集中在一只固定的蓝色盘子上,换盘后立刻翻车。从那时起,每个项目我都会在交付报告里附一组 bad case 热力图,用这套方法说服业务方接受“模型会失败”这件事。希望帮到你。
本文还有配套的精品资源,点击获取