
简介面向深度学习初学者与计算机视觉爱好者的CNN食物图像识别项目基于Python和TensorFlow覆盖数据预处理、模型搭建、训练到分类预测全流程可应用于餐饮、健康监测等场景。资源共32个文件17.28MB以12个Python脚本为核心涵盖模型定义、图像变换、数据加载、训练与结果对比并附tfrecords数据集、模型权重及配置说明目录清晰便于复现。已有6643人学习下载。通过实际操作可掌握卷积层、池化层、全连接层及激活函数的协同机制体验数据增强与迁移学习等进阶方法深入理解CNN原理提升图像识别实战能力。1. 从“拍了不知道吃了啥”说起基于CNN的食物图像识别工程好在哪你在食堂结算台端着餐盘排队摄像头扫一眼账单自动列出番茄炒蛋和红烧排骨手机里打开饮食记录App拍一张午饭照片识别出这是三杯鸡套餐还是黄焖鸡米饭。这些场景背后是同一个技术问题把二维像素矩阵映射到一个菜品类目而目前主流解法就是基于卷积神经网络CNN的食物图像识别。食物图像识别比普通物体分类更麻烦同一道宫保鸡丁不同厨师做出来色泽和装盘差异巨大同一家店换个灯光色温整张照片的颜色分布就换了。早年靠颜色直方图和边缘特征手工搭流水线的方案在真实餐盘照片里几乎撑不过验证集。这份工程围绕卷积神经网络的食物图像识别整理了一条完整可跑链路数据集目录怎么组织、增强参数怎么设、模型怎么迁移学习、训练完怎么单张推理还附了一组我从实际项目里沉淀下来的踩坑记录。适合刚接触CNN想做出第一个可用识别模型的开发者也适合想做菜品识别演示或想迁移学习快速出版本的从业者拿来改改数据就能跑通。2. 先立理论再选型食物分类为什么躲不开卷积神经网络2.1 传统特征工程在食物图像上的三个死穴早几年做食物识别常见套路是SIFT或ORB提取关键点描述子再用HOG统计梯度方向最后把特征喂给SVM或随机森林分类器。这套流程在工业零件、车牌这类几何结构稳定的目标上效果不错但一碰到食物照片就开始失灵。根本原因不是分类器不行而是手工特征表达不了食物外观的复杂变化。第一个死穴是形状不稳定。一份红烧肉被切成五块还是八块边缘响应完全不一样米饭被勺子压成球形还是摊在盘子里HOG特征直接变成两种东西。传统特征隐含着一个前提目标有相对稳定的几何结构食物恰恰是最不满足这个前提的类别之一。第二个死穴是颜色受光照支配。黄色灯光下拍的炸鸡和偏暖灯光下拍的煎蛋肤色非常接近白平衡一变整个颜色直方图跟着漂移特征分布立刻乱掉。第三个死穴是特征维度膨胀后难以泛化。几千维的SIFT词袋特征换个餐厅、换块桌布特征分布就变了换一组数据验证集性能直接打回原形。CNN改变的是特征这件事本身卷积核自动从像素里学边缘、纹理、局部形状再逐层组合出适合当前任务的语义特征不需要人工先定义什么叫“红烧”、什么叫“酥脆”。这也是为什么现在食物识别很少再有人用传统特征搭流水线除非类别只有个位数且拍摄条件被严格控制。食物识别比一般物体分类更接近细粒度图像分类类内的颜色和纹理变化甚至比类间差异还大这条分界线让手工特征越发难以画出来。2.2 网络结构怎么定从ResNet到MobileNet的取舍先破一个容易绕弯路的地方有人搜“卷积神经网络”时会搜到大量一维卷积网络的文献。一维卷积更适合语音、文本这类序列信号输入是一维数组卷积核只在时间轴上滑动。食物图像是一张二维的像素矩阵空间相邻关系极其重要强行把图像展平成序列喂给一维卷积会打散邻近像素的局部结构效果大概率比一个普通二维小网络还差。所以这份工程全部以二维CNN为主线下面也只在二维模型里挑。选型时我习惯先问部署目标。如果只是本地实验类别超过50个用ResNet50残差结构在ImageNet上训出来的预训练权重好找微调时不容易退化如果后面要接到手机小程序、嵌入式摄像头或者无GPU的服务器上用MobileNetV3深度可分离卷积能把计算量压到很低。食物识别的类别差异大不太需要极致细粒度模型ResNet和MobileNet在验证集上的差距常常被数据噪声淹没真正的短板通常出在数据质量而不是网络结构。结构参数量量级预训练权重适合场景ResNet50约25M常见服务端训练、类别较多、对精度要求高MobileNetV3约4M~5M常见端侧推理、CPU部署、内存吃紧EfficientNetB0约5.3M常见精度与推理速度都需要兼顾时表里的数字只是常用实现的参数量量级具体跟版本有关不必纠结到个位。还有一个细节值得注意ResNet50默认输入224×224MobileNetV3可以跑160或192的分辨率分辨率一降推理速度快一截代价是验证集准确率通常会掉一点。我一般先按224把流程跑通最后再测一遍降分辨率到底损失多少如果损失在一个百分点以内就果断用低分辨率换速度。另外网络输入尺寸必须和预训练权重匹配输入尺寸不匹配时预处理阶段的resize逻辑会跟着错位这是很多复现失败跑到一半才暴露的隐性原因。2.3 这份工程的目录结构与训练主线这份工程按一条完整主线组织图像数据 → 预处理 → 训练模型 → 保存权重 → 单张推理。拿到工程包先看目录结构不用逐行读代码搞清楚每个文件负责什么再动手避免后面改错位置。目录/文件作用data/train、data/val、data/test划分后的图像数据集子目录名就是类别名scripts/build_dataset.py把原始图片按比例划分到train/val/testscripts/train.py数据增强、构建模型、执行训练全流程net/model.py模型结构定义默认ResNet50迁移学习weights/训练过程中自动保存的最佳权重inference.py加载权重对单张图片做预测requirements.txt依赖清单训练主线的调用顺序固定如下我在工程里把参数都设了默认值先跑通再改python scripts/build_dataset.py --data_dir raw_images --out_dir data python scripts/train.py --model resnet50 --epochs 50 python inference.py --image test.jpg --weights weights/food_resnet50.h5每个脚本都支持--help查看参数。build_dataset.py会把raw_images里每个子目录当作一个类别按比例随机分配到三个目录train.py负责加载增强后的数据并启动训练inference.py只做推理不依赖训练环境。--data_dir指向原始图片根目录根目录下每一层目录名都要保持英文--model支持resnet50和mobilenetv3两个选项--epochs只设上限实际由EarlyStopping决定。这套工程解决的是从零搭建一条可运行的CNN食物分类流水线的问题拿到手先把默认参数过一遍再替换成自己要识别的菜品。3. 数据集组织与预处理目录结构决定训练命运Keras的ImageDataGenerator和PyTorch的ImageFolder都有一个共同约定目录名就是类别标签。这意味着数据组织的正确性直接影响训练能否开始而这一步恰恰是最容易在复现时翻车的。3.1 数据目录规范与划分方式工程里要求数据集按如下目录结构摆放子目录名是类别名不能出现中文、空格或括号data/ train/ apple/ rice/ chicken/ val/ apple/ rice/ chicken/ test/ apple/ rice/ chicken/不少新手直接把所有jpg堆在一个文件夹里再用一个csv写标签Keras这边就得多写一层数据加载逻辑而且csv错一行整批标签错位排查起来非常痛苦。目录名即标签的方案虽然老派但最简单可靠。数据划分我习惯用脚本而不是手动拖拽手动划分无法复现分了三次每次都不同后面做对比实验没有公平基础。下面是划分脚本的核心逻辑import os import random import shutil def split_dataset(src_root, out_root, train_ratio0.7, val_ratio0.2, seed42): random.seed(seed) for class_name in os.listdir(src_root): class_dir os.path.join(src_root, class_name) if not os.path.isdir(class_dir): continue images sorted(os.listdir(class_dir)) random.shuffle(images) n_total len(images) n_train int(n_total * train_ratio) n_val int(n_total * val_ratio) for phase, idx_range in [(train, range(n_train)), (val, range(n_train, n_train n_val)), (test, range(n_train n_val, n_total))]: dest_dir os.path.join(out_root, phase, class_name) os.makedirs(dest_dir, exist_okTrue) for i in idx_range: src os.path.join(class_dir, images[i]) shutil.copy2(src, os.path.join(dest_dir, images[i]))代码逻辑先对每个类别单独shuffle再按比例切三段确保每个类别在三个集合里都有样本而不是全局混在一起切避免某个类别在测试集里一个样本都没有。train_ratio、val_ratio分别控制训练集和验证集比例剩余全部作为测试集。seed固定为42别人复现时用同一个seed得到完全相同的结果。如果你只有几千张图片建议不要把val设到0.3验证集太小会让EarlyStopping误判7:2:1是食物识别场景里比较稳的默认值。提示目录名保持英文或拼音中文映射放到后端的label_map里不要在文件名阶段引入编码风险。3.2 数据增强不是越多越好要看场景数据增强是训练时让模型“多看几种拍法”的手段但食物照片有自己的特殊性。我见过不少新项目直接套ImageNet分类的增强参数rotation_range设90vertical_flip开True结果验证集准确率一直在低位徘徊。原因其实不难理解一张俯拍的餐盘照片水平翻转后还是人眼能理解的摆盘但上下翻转后盘子里的菜倒过来了这在真实拍摄中几乎不会出现。模型被迫学到“倒着的菜”这种不存在于真实分布的特征反而干扰正常识别。水平翻转可以开因为手机横着竖着拍餐盘都很常见垂直翻转我默认关掉除非你的数据集里确实包含上下颠倒的拍摄角度。增强参数推荐值理由rotation_range15允许小幅旋转模拟手抖太大破坏方向信息width_shift_range0.1模拟餐盘偏移height_shift_range0.1模拟餐盘偏移zoom_range0.1模拟距离变化幅度控制在10%horizontal_flipTrue手机横竖屏拍餐盘都合理vertical_flipFalse俯拍图的上下颠倒不真实brightness_range[0.9, 1.1]模拟餐厅灯光色温变化如果发现验证集损失一骑绝尘、训练集还很低优先增强这一栏里的brightness和rotation而不是无脑把rotation调到45。食物识别的真实难点是“同一个菜在不同餐厅、不同灯光下长什么样”灯光变化的模拟比大幅旋转更有价值。3.3 预处理代码与参数说明确定好增强参数后工程里用Keras的ImageDataGenerator完成预处理完整配置如下from tensorflow.keras.preprocessing.image import ImageDataGenerator train_gen ImageDataGenerator( rescale1.0 / 255, rotation_range15, width_shift_range0.1, height_shift_range0.1, zoom_range0.1, horizontal_flipTrue, vertical_flipFalse, brightness_range[0.9, 1.1] ).flow_from_directory( data/train, target_size(224, 224), batch_size32, class_modecategorical, shuffleTrue )rescale把像素从0-255归一化到0-1让数值尺度稳定训练更容易收敛。严格来说ImageNet预训练模型更推荐用per-channel的均值和标准差做归一化但工程里用1/255缩放在绝大多数食物识别任务上效果差异很小可以先用默认值跑通最后再回头测要不要换更严格的归一化。target_size必须与模型输入一致ResNet50默认224×224不要设成256后去加载224的预训练权重resize逻辑一乱整个特征就错位。batch_size在单卡6G显存配置下建议32显存紧张可以先降到16但不要低于8否则BatchNorm层的统计量会不稳定。class_mode设categorical因为食物分类是多类互斥任务标签做one-hot编码如果后面改成二分类就改成binary。ImageDataGenerator是在训练过程中实时做增强每个epoch都会生成不同的变换版本好处是能覆盖更多拍摄场景坏处是CPU会成为瓶颈所以建议把worker数调高这块在避坑章节还会细说。4. 模型训练与调参让损失真正降下来数据准备好就能开始训练但训练不等于直接跑model.fit。食物识别工程里最值得先想清楚的是迁移学习策略因为绝大多数项目没有几十万张食物图片的数据基础从零训练在可操作性上基本是负数。4.1 迁移学习与冻结层策略用ImageNet预训练权重做初始化是食物识别工程里最常见的正确做法。ResNet50在ImageNet上学到的边缘、纹理、局部形状特征对食物照片同样有效因为食物图像也是真实世界照片底层视觉特征高度复用。关键决策是冻结多少层我的默认流程分两步。第一步全冻结只训练新增的全连接层跑3到5个epoch把随机初始化的分类头先稳定下来。第二步解冻backbone后几十层用很小的学习率微调。不要一开始就全层解冻那样随机初始化的分类头会产生很大梯度反向传播直接冲击预训练权重损失曲线会先冲高再慢慢爬回来白白浪费几个epoch。from tensorflow.keras.applications import ResNet50 from tensorflow.keras import layers, models base ResNet50( include_topFalse, weightsimagenet, input_shape(224, 224, 3), poolingavg ) base.trainable False model models.Sequential([ base, layers.Dropout(0.5), layers.Dense(256, activationrelu), layers.Dropout(0.3), layers.Dense(num_classes, activationsoftmax) ])include_topFalse表示去掉原模型的1000类分类头只保留特征提取部分weightsimagenet加载预训练权重如果当前环境无法联网下载这一步会卡住需要先离线准备好权重文件放到缓存目录再设weights为权重路径。poolingavg把最后一层特征图做全局平均池化直接压成一维向量比Flatten参数更少也不容易过拟合。中间全连接层设256、Dropout设0.5是平衡能力和泛化的常用起点如果类别数很少小于10个256可以改成128避免Dense层成为过拟合的温床。第二步解冻时我会把base.trainable设为True并且只解冻后半段通常做法是让ResNet50的conv4、conv5可训练前面的conv1到conv3继续冻结。这一步的学习率必须从1e-3降到1e-5量级否则预训练权重会被破坏。4.2 训练主循环与关键超参数训练超参数不是拍脑袋定的我按下面的默认配置起步跑几个epoch看曲线再调from tensorflow.keras import optimizers from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau model.compile( optimizeroptimizers.Adam(learning_rate1e-3), losscategorical_crossentropy, metrics[accuracy] ) callbacks [ EarlyStopping(monitorval_loss, patience8, restore_best_weightsTrue), ReduceLROnPlateau(monitorval_loss, factor0.5, patience3, min_lr1e-6), ModelCheckpoint( weights/food_resnet50.h5, monitorval_loss, save_best_onlyTrue ) ] model.fit( train_gen, validation_dataval_gen, steps_per_epoch200, validation_steps50, epochs50, callbackscallbacks )compile里loss用categorical_crossentropy对应多类别互斥分类Adam初始学习率1e-3是分类头阶段的设置微调阶段要手动改成1e-5再compile一次。EarlyStopping监控val_loss连续8个epoch没有下降就停止restore_best_weightsTrue保证停止时恢复到最优权重而不是停在最后一个epoch。ReduceLROnPlateau每3次停滞就把学习率减半最低到1e-6这个机制比固定epoch数更省心。ModelCheckpoint里我建议监控val_loss而不是val_acc因为accuracy对分类边界敏感偶尔一跳容易保存到假的最优值。steps_per_epoch和validation_steps这里设的是固定值实际工程里用train_gen.samples除以train_gen.batch_size去算更保险数字不用背代码里直接取属性就行。epochs设50只是个上限早停机制会提前结束不用怕设大了。超参数默认值调整建议初始学习率1e-3微调阶段改1e-5batch_size32显存不够降到16不要低于8Dropout分类头0.5 / 0.3过拟合时往0.6提欠拟合往0.3降EarlyStopping patience8数据量小时降到5防止训练太短ReduceLROnPlateau factor0.5loss平台期明显时用0.3更狠4.3 训练日志怎么读loss不降时先按顺序排查训练启动后不要只盯着终端滚屏我习惯直接开TensorBoard看两条曲线train_loss和val_loss。判别方向很简单train_loss在降、val_loss不动先怀疑过拟合优先加Dropout和增强强度而不是去加深网络train_loss和val_loss都不降先减学习率或确认预处理是否正常很多所谓“模型不收敛”其实是学习率太大导致loss来回震荡第一个epoch就出现NaN先查数据里有没有损坏图片和NaN标签再查是否开了非常大的学习率。这里有一个原则同一时间只改一个变量。加了增强就不要同时改网络结构改了学习率就不要顺手换模型否则两个变量互相干扰根本定位不到问题根源。我见过太多人把Dropout、学习率、网络深度一起改最后模型训坏了也不知道是哪一步造成的。5. 避坑手册食物识别最容易翻车的五个现场避坑部分放在训练代码之后是有意的很多问题必须真正训过一轮才能对上号。下面五条是我在实际跑食物识别项目时踩过、并且至少见过三次以上的翻车现场统一按“现象→原因→解决”展开排查时可以直接对照。5.1 训练集acc到0.99、验证集只有0.70过拟合在作怪现象训练集准确率快速冲到0.99验证集准确率始终在0.70附近早停怎么等都提不上去训练和验证的差距越来越大。原因模型容量大、数据量相对少把训练集里的背景特征也学进去了。食物识别尤其容易过拟合到背景因为很多爬虫抓来的同一家店多张图片桌布、盘边反光、灯光色温都是一样的模型记住这些比记住“这是红烧排骨”容易得多。解决顺序不能反。第一步加Dropout、加增强强度重点加brightness_range模拟不同餐厅灯光第二步检查数据集里有没有重复图片同一张图既进了训练集又进了验证集会造成验证假象最后才考虑缩小Dense层节点。不要上来就换网络结构ResNet50在食物分类上基本够用过拟合绝大多数时候是数据侧和正则侧的问题。5.2 loss在降、acc卡在50%左右类别不均衡还是标签错位现象loss一路下降看着很正常准确率却卡在50%以下或者某一类几乎从来不被预测出来。原因第一种是类别样本悬殊有的类几百张、有的类只有几十张模型偏向多数的类第二种是标签错位比如某个目录下混入了别的菜或者class_indices的映射和你的预期顺序对不上。解决先统计每类样本数低于均值三分之一就考虑过采样再打印class_indices核实目录到标签的映射。我习惯在训练开始前跑下面这段确认标签没有错位class_indices train_gen.class_indices print(class_indices)如果打印出来的映射顺序与你预期不一致不要靠记忆硬编直接按class_indices里的顺序读取即可。确认标签没有问题后再看分类头输出节点数是否与类别数一致Keras里flow_from_directory会按目录总数自动生成节点数但如果你手动改了模型结构这里很容易多一个或少一个。5.3 图片加载慢到怀疑人生IO才是真瓶颈现象GPU利用率很低训练进度条在数据加载那一步长时间停顿整个epoch的时间比模型计算时间还长。原因机械硬盘或海量小文件拖慢了IO。几百个类别、每个类别几千张jpg直接从目录逐个读取文件操作系统在打开文件上浪费的时间远超图像解码的时间。解决先给flow_from_directory设置workers4和use_multiprocessingTrue这个改动最直接。还慢的话就把图片打包成TFRecord或HDF5格式一次性读入内存这两类格式在工程里没有默认启用但它们解决的问题是真切的。另外把数据集放到SSD上比换一块更贵的GPU更有效IO瓶颈不在算力。5.4 薯条和炸鸡块老是互相认错细粒度特征不够现象混淆矩阵里两个高相似类别交叉严重比如炸鸡块和薯条、面包和蛋糕准确率被这两类拖下去一大截。原因模型拿到的判别信息不够细。食物识别里炸鸡块和薯条颜色接近、尺寸接近真正的区分点在表皮纹理和形状如果数据增强里的zoom和rotation过猛这些细微纹理被抹掉了模型只能依赖颜色这种粗粒度特征当然分不开。解决针对容易混淆的类别做专项验证。我一般用Grad-CAM看一下模型在分类时把注意力放在哪里如果它盯着盘子边缘反光而不是食物主体说明训练数据里主体被背景干扰了。这种情况下无脑增强没用正确的做法是对这两个类单独补拍数据或者降低zoom_range避免破坏纹理细节。注意细粒度混淆问题靠增加网络深度解决的概率不大模型深度带来的收益通常被数据纹理损失抵消优先从数据和增强侧找原因。5.5 显存不足OOM分辨率、batch和尺寸的取舍现象训练跑了一两个step后报CUDA out of memory进程被杀前面所有进度全部作废。原因输入224×224、batch32、ResNet50在6G显存的卡上刚好在内存边界一旦数据增强或验证阶段多开销一部分显存就爆了。解决先做最保守的改动把batch_size降到8通常立刻缓解还爆就把target_size从224降到192因为Keras应用模型对输入尺寸有一定弹性降分辨率后池化层可以吸收尺寸变化模型结构不需要变。最后一步是开启混合精度from tensorflow.keras import mixed_precision mixed_precision.set_global_policy(mixed_float16)混合精度用半精度计算显存占用直接降三分之一左右但对精度有微小影响一般食物识别任务可以接受。注意开启混合精度后学习率可能需要略调低否则个别层会不稳定。最后一招才是换小模型从ResNet50切到MobileNetV3这不是首选方案因为模型切换会影响整个实验基线。6. 进阶验证用混淆矩阵把模型的短板挖出来6.1 单张推理与置信度检查模型训练完成后最直接的一个验证动作是拿没见过的图片做单张推理。这里最容易犯的错误是忘了预处理对齐训练时用了rescale1/255推理时也要做完全相同的归一化否则模型输入的数值分布和训练时不一致置信度会整体漂移。import numpy as np from tensorflow.keras.preprocessing import image from tensorflow.keras.models import load_model model load_model(weights/food_resnet50.h5) img image.load_img(test/rice_001.jpg, target_size(224, 224)) x image.img_to_array(img) / 255.0 x np.expand_dims(x, axis0) probs model.predict(x)[0] top3 np.argsort(probs)[::-1][:3] for idx in top3: print(label_map[idx], round(probs[idx], 4))predict返回的是softmax概率分布argsort取前三个是因为食物识别里目标类别经常有近义类比如三杯鸡和照烧鸡排在第二第三很正常只看argmax会造成误杀。这个动作我每次替换数据集后都会跑五张样本图检查人工主观判断和模型top3是否一致。6.2 用混淆矩阵定位系统性问题全局acc只能告诉你整体水平告诉不了你哪类在拖后腿。我见过全局acc到了0.91的项目某类“粥”的召回率只有0.31这通常意味着这一类在训练集里被压得极薄或质量混乱。用混淆矩阵可以快速看到这个差异from sklearn.metrics import confusion_matrix, classification_report conf confusion_matrix(y_true, y_pred) report classification_report( y_true, y_pred, target_nameslist(label_map.values()), digits3 ) print(report)y_true来自验证集的真实标签y_pred是模型对验证集的预测结果。看classification_report时重点盯recall列凡是低于0.5的类都是下一次迭代要补数据或调增强优先处理的类。全局acc高但某一类recall很低属于典型的“平均性能掩盖局部失灵”这类问题在真实部署中往往比错误率本身更危险因为用户可能专点那个菜来识别。6.3 让验证成为一种习惯有一次我训练完模型全局准确率到了0.91觉得差不多了直接拿去一个demo里跑。结果换成餐厅真实照片同一道清炒时蔬换几个角度拍识别结果一直在换。最后用混淆矩阵一查才发现“清炒时蔬”类里混了沙拉、生菜、西蓝花三种风格完全不同的子类对应类的召回率只有0.43全局acc带着水分骗了我一整轮迭代。从那以后我每次迭代都强制跑一遍固定流程打印class_indices核对标签统计每类样本数跑一次验证集混淆矩阵再单张推理五张新图。这套流程加起来十几分钟但能挡掉所有“我以为可以上线、一上线就露馅”的情况。代码和工程包我整理在本次资源里拿到手先按默认参数完整跑一遍再替换成自己的数据集祝你能把第一个食物识别模型顺利跑上线。希望帮到你。本文还有配套的精品资源点击获取