简介针对食物图像识别场景这套资源提供了一份可运行的卷积神经网络项目采用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-1011011000迁移学习、通用效果验证Vireo Food-172172约5000细粒度识别类别更细致自建自定义建议≥300/类特定餐厅、食堂、地域菜品在做这里之前我还要解释一个经常被搜到的点网上很多一维卷积神经网络介绍的文献讲的是心电图、语音这类一维信号卷积核在时间轴上滑动但食物照片是二维矩阵卷积核要在宽度和高度两个方向滑动。如果你是从 NLP 转过来看图像别把一维 CNN 的实现逻辑硬搬到图像上二维卷积核的感受野和步长设计完全不同。自建数据集还需要注意“标签标准化”。我见过一个项目里“西红柿炒鸡蛋”和“番茄炒蛋”同时存在这两类在视觉上几乎没有稳定区分度导致模型 top-1 永远在这两个类之间跳。宁可把标签合并成“番茄炒蛋”也不要为犟嘴保持双标签。类目设计要满足互斥性一张图只能属于一个类如果一张餐盘里有三个菜那它就要被排除或者单独建一个“套餐”类别硬塞进某一个单独菜品。2.2 预处理resize、归一化与增强的工程取舍CNN 输入尺寸越接近训练集分布越好。迁移学习时一般沿用预训练模型的输入尺寸ResNet 系常用 224x224EfficientNet 可能是 240 或 260。不要凭想法改成 320x320除非你有足够算力和更多训练数据。放大输入会把 pretrain 里见过的空间尺度改变导致模型需要重新适应。最常见预处理组合是读取图片 → 最短边缩放 → 中心裁剪或随机裁剪 → 转 Tensor → 按 ImageNet 的 mean/std 归一化。ImageNet 的均值是[0.485, 0.456, 0.406]标准差是[0.229, 0.224, 0.225]。为什么要用这一组因为预训练模型是在这个分布上收敛的输入数据保持同一分布才能让冻结或微调的权重继续生效。如果你自己从零训练可以不遵循这套均值但除非必要别改。食物图像有一个很具体的坑颜色是强特征尤其对于红烧肉、宫保鸡丁这种强色型菜品亮度扰动对结果影响很大。所以做数据增强时随机亮度/对比度调整的幅度不要像 ImageNet 分类那么狠。我一般用ColorJitter(brightness0.2, contrast0.2, saturation0.2, hue0.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, trainTrue): 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(brightness0.2, contrast0.2, saturation0.2, hue0.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_size32, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue )drop_lastTrue在最后一个 batch 不足时丢弃这能让 BN 层的统计更稳定num_workers4在 Windows 上常常要设成 0否则多进程会反复启动报错pin_memoryTrue适合 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(pretrainedTrue) num_classes 12 # 替换最后一层让输出维度对应食物类别数 in_features model.fc.in_features model.fc torch.nn.Linear(in_features, num_classes)pretrainedTrue会自动下载resnet18-f37072fd.pth权重。第一次运行会需要网络建议把torch.hub的缓存目录指定到有空间的路径。不要直接改model.fc.out_features因为旧全连接层的权重矩阵还保留着改成类数后参数形状不对运行时会报维度不匹配。新版 torchvision 推荐用weightsmodels.ResNet18_Weights.IMAGENET1K_V1替代pretrainedTrue两者都能用但显式写法更清晰。如果你想换成 ResNet50把类名替换即可输入尺寸还是 224x224。3.3 必调的三个超参数学习率、batch size、权重衰减迁移学习的核心法则是冻结部分在前微调部分在后学习率一般要小。常用配置batch size 32 或 64初始学习率 1e-4 到 3e-4权重衰减 1e-4。如果只训练新分类头可以把学习率放到 1e-3如果对整个网络微调就按 1e-4 来。为什么不把学习率设大因为预训练权重的梯度不太希望被一次性冲掉大学习率很容易让底层学到的纹理特征被噪声覆盖。优化器一般用 AdamW 或 SGDmomentum。SGD 最终精度往往比 Adam 更稳但调起来麻烦。数据量在几千张时我一般直接用 AdamW初始 lr 1e-4weight_decay 1e-4。下面是一段可复用的训练配置import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR criterion torch.nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max30, eta_min1e-6)CosineAnnealingLR把学习率在 30 个 epoch 内按余弦曲线从 1e-4 降到 1e-6。对视觉任务来说开始阶段学习率不宜衰减太快后段低学习率有助于收敛到平滑点。如果你用 SGD别忘了加momentum0.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(dim1) 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_locationcpu否则会报 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_size0.2, stratifylabels, random_state42 )stratifylabels让训练集和验证集保持相同菜品比例。如果你的数据采集自多个批次最好在传入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, labelsclass_names) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsclass_names, yticklabelsclass_names) plt.show()看混淆矩阵时别只盯着对角线。我一般会找矩阵里非对角线最高的几个位置比如“番茄炒蛋”被误判成“西红柿鸡蛋汤”那就要去检查这两类的样本是不是采集的图像风格很相近或者标签本身不一致。此时不要急着加数据先看训练集的原始图很多时候是标注错误比如一张图同时包含两种菜标签只给了其中一种。4.4 Early Stopping 和数据增强压制过拟合的实战组合控制过拟合的优先级先减少模型容量或增加正则再考虑加数据。实际做法是盯验证集 loss连续 5 个 epoch 不降就停。但注意 patience 不要设太大否则浪费训练时间。我一般用class EarlyStopping: def __init__(self, patience5, min_delta0.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 没有设置shuffleTrue或者数据集路径洗牌后又喂给同一个 batch还有可能是CrossEntropyLoss的输入和标签的 device 不一致关键计算被放到 CPU 上。解决先打印一个 batch 的 labels 分布再确认imgs, labels batch都.to(device)最后把 DataLoader 里的shuffleTrue打开。注意 DataLoader 的generator如果不固定随机种子每次重启结果也会变因此复现时设置torch.manual_seed(42)和generatortorch.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(), lr1e-3)如果需要解冻骨干网络再设置除model.fc以外的层为requires_gradTrue并且重新把参数过滤给优化器。如果你使用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(modelmodel, target_layers[model.layer4[-1]]) grayscale_cam cam(input_tensor, target_categorypred_class) heatmap show_cam_on_image(denormed_img, grayscale_cam, use_rgbTrue)target_layers选择最后一个卷积层因为它的空间分辨率还在语义信息最强。target_category用预测类别而不是真实标签这样能看到模型当前决策的依据。如果热力图大面积落在盘子边缘或桌面倒影上说明模型被背景污染了。6.2 把 CAM 当作项目验收的一环我一般在项目结束前从每一类里随机抽 5 张图跑一批 CAM 图。如果某类菜的激活区域稳定在食物上说明这个类学到的特征是可复用的如果激活区域乱跑就需要回到数据层处理。我最早部署时只盯着准确率后来把 CAM 纳入验收流程才发现好几个类的激活集中在一只固定的蓝色盘子上换盘后立刻翻车。从那时起每个项目我都会在交付报告里附一组 bad case 热力图用这套方法说服业务方接受“模型会失败”这件事。希望帮到你。本文还有配套的精品资源点击获取