简介:基于卷积神经网络的食物图像识别项目完整代码与数据包,面向具备Python基础并希望实践CNN图像分类的开发者与学习者。资源围绕TensorFlow与tf.keras构建食物识别模型,涵盖数据预处理、网络定义、训练评估与预测全流程,并引入迁移学习思路供进阶参考。压缩包共32个文件,约17.28MB,其中包含12个Python源码(模型构建、训练、测试及图像转换)、训练集TFRecords、模型权重,以及项目配置与说明文档,目录结构清晰便于直接运行和二次开发。已有6701人学习下载,尤其适合用作物联网、AI课程设计、毕业设计或入门深度学习的实操素材。通过运行资源包,读者可复现从原始图片到食物类别预测的完整流程,理解卷积层、池化层与全连接层的作用,并在真实数据集上观察模型调参效果。1. 基于卷积神经网络的食物图像识别:拍一张照就能判断菜品的完整实践这份资源做的是“拍一张菜品照片,几秒内输出它属于哪个类别”的图像识别项目。和手写数字、猫狗分类这类入门 Demo 不同,食物类别之间视觉差异很小,比如不同做法的土豆、相近颜色的汤羹,模型很容易翻车。项目用卷积神经网络搭建了一套从数据集预处理、模型训练到单张图片预测的完整流程,适配消费级显卡就能跑,适合想从分类任务过渡到真实场景识别的开发者。如果你是第一次接触图像识别,或者已经跑通过几个 MNIST 项目、想看看更高难度分类任务怎么做,这份资源可以直接照着复现。2. 架构与技术选型:为什么食物识别要这样搭模型2.1 食物识别的难点决定了模型结构刚开始接触这类项目时,我习惯直接用现成的分类网络跑一遍。但食物图像识别有一个显著特点:类间差异小、类内差异大。同样是“土豆”这个类别,可能包含炒土豆丝、土豆炖牛肉、烤土豆片,外观差异极大;而“糖醋里脊”和“菠萝咕咾肉”从颜色和质感上看又非常接近。这种数据分布对模型的特征提取能力要求更高,单纯堆卷积层数并不能解决全部问题,需要在网络结构上做针对性设计。项目采用的基础骨架是 ResNet。选择 ResNet 而不是 VGG 或 GoogLeNet,原因是残差结构能有效缓解网络加深带来的梯度消失问题。传统 VGG 网络堆到十几层之后,反向传播时梯度很容易衰减,训练收敛非常慢。ResNet 通过跳跃连接把前一层的输出直接加到后一层,让梯度能有一条“高速公路”传回浅层,这样网络就能安全地加深到 50 层以上,而不出现训练不收敛的玄学问题。在 ResNet 基础上,项目做了一个关键调整:把网络末尾的全连接层替换成自定义的分类头。原始 ResNet 在 ImageNet 上训练时输出 1000 类,而这个项目要识别的食物类别只有几十种,所以需要把最后的全连接层改成对应数量的输出。另外,在全局平均池化之后接了一个 Dropout 层,概率设为 0.2,用来抑制过拟合。食物数据集的样本量通常不大,如果不加 Dropout,训练集准确率很容易冲到 99%,但验证集表现平平。2.2 项目目录结构与数据流动拿到项目源码后,第一件事不是急着跑训练,而是把目录结构理清楚。常见的图像分类项目会有模型定义、数据加载、训练脚本、推理脚本、工具函数这几个模块分开存放,这份资源也不例外:food-classifier/ ├── config.py # 全局配置:路径、超参数、类别数 ├── dataset.py # 数据加载与增强 ├── model.py # 卷积神经网络结构定义 ├── train.py # 训练主流程 ├── infer.py # 单张图片推理 ├── utils.py # 工具函数:日志、模型保存 ├── requirements.txt # 依赖列表 ├── data/ │ ├── train/ # 训练集,按类别分文件夹 │ ├── val/ # 验证集 │ └── test/ # 测试集 └── checkpoints/ # 保存训练好的模型权重数据流是这样走的:训练脚本从config.py读取超参数,然后调用dataset.py中的 DataLoader 加载图片,经过随机裁剪、翻转、归一化等预处理后,逐批送入model.py定义的卷积神经网络。前向传播算出预测结果,与真实标签计算交叉熵损失,反向传播更新权重。每个 epoch 结束后在验证集上评估准确率,并把最优权重保存到checkpoints目录。推理时,infer.py加载保存的权重,对单张图片走同样的预处理流程,最终输出每个类别的概率分布。config.py是整个项目的配置中心,所有可调的参数都集中在这里。学习率、批量大小、训练轮数、输入图片尺寸、类别列表、模型保存路径,都会在这个文件里定义。实际改造项目时,先改这个文件就能跑通大半流程,不用到处翻代码找参数。3. 核心模块拆解:一张菜品图片从加载到输出概率的完整链路3.1 数据加载与增强模块食物识别项目里,数据质量直接决定模型的最终效果。dataset.py文件实现了一个继承自 PyTorchDataset类的自定义数据集,核心逻辑是读取图片文件、做数据增强、返回张量。代码结构大致是这样的:import os import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class FoodDataset(Dataset): def __init__(self, root_dir, transform=None): self.root_dir = root_dir self.transform = transform self.classes = sorted(os.listdir(root_dir)) self.class_to_idx = {cls: idx for idx, cls in enumerate(self.classes)} self.images = [] for cls in self.classes: cls_dir = os.path.join(root_dir, cls) for img_name in os.listdir(cls_dir): if img_name.endswith(('.jpg', '.png', '.jpeg')): self.images.append((os.path.join(cls_dir, img_name), self.class_to_idx[cls])) def __len__(self): return len(self.images) def __getitem__(self, idx): img_path, label = self.images[idx] image = Image.open(img_path).convert('RGB') if self.transform: image = self.transform(image) return image, label这段代码的核心逻辑不复杂:初始化时扫描根目录下的子文件夹,把每个子文件夹名当作一个类别,文件夹内的每张图片对应一个标签。__getitem__方法按索引返回一个预处理后的图片张量和它的类别索引。值得注意的一个细节是Image.open(img_path).convert('RGB')。如果训练集中混入了灰度图或 RGBA 格式的 PNG 图,不统一转换成 RGB,训练时会在数据送入网络的那一步报通道数不匹配的错误。这个转换操作虽然消耗一点时间,但能避免后面排查数据格式兼容性问题。数据增强部分,项目用的是 PyTorch 官方的torchvision.transforms组合,常见的配置是:train_transform = T.Compose([ T.RandomResizedCrop(224, scale=(0.6, 1.0)), T.RandomHorizontalFlip(), T.RandomRotation(15), T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = 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]) ])训练集和验证集用不同的预处理策略。训练集用了随机裁剪、水平翻转、随机旋转和颜色抖动,目的是让模型看到同一道菜的不同角度、不同亮度表现,增强泛化能力。验证集不做随机增强,只做缩放和中心裁剪,保证评估结果的稳定性。这里的Normalize参数用的是 ImageNet 数据集的均值和标准差,因为 ResNet 预训练权重是在 ImageNet 上生成的,如果换一套归一化参数,模型加载预训练权重后特征分布就不匹配了。3.2 卷积神经网络模型定义model.py中定义了整个网络结构。项目没有从零手写每一个卷积层,而是使用 PyTorch 自带的torchvision.models加载 ResNet 预训练模型,再修改分类头。这种迁移学习的做法在食物识别这类中规模数据集上非常有效,因为底层卷积核学到的边缘、纹理特征在通用图像上已经足够好,只需要重新训练高层分类器即可。import torch.nn as nn import torchvision.models as models class FoodClassifier(nn.Module): def __init__(self, num_classes, model_name='resnet50', pretrained=True): super(FoodClassifier, self).__init__() if model_name == 'resnet50': self.backbone = models.resnet50(pretrained=pretrained) elif model_name == 'resnet18': self.backbone = models.resnet18(pretrained=pretrained) else: raise ValueError(f"Unsupported model: {model_name}") in_features = self.backbone.fc.in_features self.backbone.fc = nn.Sequential( nn.Dropout(0.2), nn.Linear(in_features, num_classes) ) def forward(self, x): return self.backbone(x)把backbone.fc整个替换成新的Sequential模块,是迁移学习的标准操作。in_features从原始全连接层中读取,这样不管换成 ResNet18 还是其他 ResNet 变体,代码都能自动适配输入维度。Dropout 加在Linear前面,注意这里的 Dropout 在训练和推理时会自动切换行为,训练时随机丢弃部分神经元,推理时保留全部,这是 PyTorch 内部处理好的,不需要手动关闭。这里有一个实际训练时需要做的选择:如果计算资源有限,用resnet18就够了,训练速度快,内存占用低;如果追求更高的准确率,用resnet50能多出约 2 到 3 个百分点。项目里默认配置的是resnet50,但也可以改配置直接切换到小模型,不用修改其他代码。前向传播非常简单,直接把输入张量传给 backbone 即可。ResNet 的前几层自己会做卷积、批归一化、ReLU 激活、残差相加的操作,对外部调用者完全透明。这种封装方式也方便后续接入 TensorBoard 观察中间层特征图,只要在forward方法里加一个 hook 就能拿到指定层的输出。3.3 训练主循环与评估逻辑训练逻辑集中在train.py中,主要步骤包括:实例化数据集和数据加载器、定义损失函数与优化器、循环迭代训练、在每个 epoch 结束后评估验证集、保存最优模型。核心训练循环代码如下:import torch import torch.nn as nn from torch.utils.data import DataLoader from dataset import FoodDataset from model import FoodClassifier import config train_dataset = FoodDataset(config.TRAIN_DIR, transform=train_transform) val_dataset = FoodDataset(config.VAL_DIR, transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=config.BATCH_SIZE, shuffle=True, num_workers=config.NUM_WORKERS) val_loader = DataLoader(val_dataset, batch_size=config.BATCH_SIZE, shuffle=False, num_workers=config.NUM_WORKERS) model = FoodClassifier(num_classes=config.NUM_CLASSES, pretrained=True) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=config.LR) def train_one_epoch(epoch): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / total accuracy = correct / total print(f'Epoch {epoch} | Train Loss {avg_loss:.4f} | Acc {accuracy:.4f}') def evaluate(): model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return correct / total每次迭代先调用optimizer.zero_grad()清空上一步的梯度,然后前向传播计算输出,用交叉熵损失计算出 loss,调用loss.backward()反传梯度,最后optimizer.step()更新参数。这个顺序是 PyTorch 的标准流程,如果漏掉zero_grad(),梯度会在多次迭代中叠加,训练结果会出现异常波动。验证集评估时一定要加torch.no_grad()和model.eval()。model.eval()会切换批归一化层和 Dropout 层的行为,让它们使用固定的统计量而不是当前批次的数据。如果不加这一行,验证集准确率会有明显波动,看起来像是模型不稳定,实际上是评估模式没有切换。交叉熵损失在这里直接处理了 Softmax。nn.CrossEntropyLoss内部已经把 Softmax 和负对数似然合并在一起,所以模型输出的原始 logits 直接传给损失函数就行,不需要在模型末尾额外加 Softmax 层。只有推理预测时,才对输出做torch.softmax得到概率分布。优化器选择上,项目使用 Adam。相比 SGD,Adam 自带自适应学习率,对学习率的选择没那么敏感,默认学习率 0.001 通常就能收敛。但如果想让效果更上一层楼,可以换成带动量的 SGD,配合余弦退火学习率调度,训练后期收敛更平稳。两种优化器各有优点,食物识别这类任务 Adam 是更稳妥的起点。3.4 推理流程与概率输出infer.py文件实现了加载模型权重并对单张图片进行预测的功能。推理和训练最大的不同在于,不需要计算梯度,也不需要 batch 的标签,只要把图片预处理成和训练时相同尺寸、相同张量格式,通过网络得到输出,再转成可读的类别名。import torch from PIL import Image from model import FoodClassifier from dataset import FoodDataset import torchvision.transforms as T def predict_image(model, image_path, class_names): transform = 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]) ]) image = Image.open(image_path).convert('RGB') input_tensor = transform(image).unsqueeze(0) model.eval() with torch.no_grad(): output = model(input_tensor) probabilities = torch.softmax(output, dim=1) top_prob, top_class = torch.max(probabilities, 1) return class_names[top_class.item()], top_prob.item()推理时给输入张量加了一个unsqueeze(0),这是因为模型期望的输入维度是(batch_size, channels, height, width)。单张图片没有 batch 维度,所以需要手动加一维。如果漏掉这一步,PyTorch 会报维度不匹配的错误。torch.softmax(output, dim=1)把网络输出的 logits 转换成概率,torch.max取出最大值对应的索引。用top_prob.item()把张量转成 Python 浮点数,方便后续展示。class_names 可以从训练集的类别文件夹顺序获取,注意训练时dataset.py中sorted(os.listdir())对类别进行了排序,推理时也要保持同样的顺序,否则概率索引对应的类别名就会对不上。4. 避坑指南:食物识别训练与部署的常见问题排查4.1 训练集准确率接近 100%,验证集却持续偏低这个现象在食物识别项目中频繁出现,特征很明显:训练 loss 一路降到接近 0,验证集准确率却只到 70% 左右就停滞了。原因基本可以锁定在两个方向:一是数据泄漏,二是过拟合。数据泄漏在食物项目的具体表现为,同一个菜品被重复拍摄后被同时分入训练集和验证集,或者数据增强方式太弱导致模型记住了训练样本。排查方法是随机抽几对训练集和验证集图片,人工对比是否有相同的图片。如果是重复数据,重新划分数据集,确保同源图片隔离。过拟合方面,先检查config.py中的 Dropout 概率是否生效。有些时候模型文件里虽然写了 Dropout 层,但加载的预训练权重把全连接层整个覆盖了,导致新加的 Dropout 没有实际参与前向传播。确认方式是打印模型结构看fc层的子模块列表。解决思路是增加数据增强的强度,比如把RandomResizedCrop的 scale 范围从(0.6, 1.0)改成(0.4, 1.0),让裁剪更多样。4.2 训练 loss 不下降或震荡剧烈训练刚启动时 loss 一直保持在 4.5 左右,过了几个 epoch 也没有明显下降趋势,这种状况通常不是模型结构问题,而是超参数设置不合理。对于 30 类左右的分类任务,随机初始化的交叉熵损失大约是log(1/30)即 3.4 左右,如果初始 loss 远高于这个值,说明模型输出被错误地初始化了。常见的原因是加载预训练权重后,新替换的全连接层没有做合适的初始化。PyTorch 的nn.Linear默认会做 Kaiming 初始化,但这里的问题在于最终的分类层直接使用随机初始化,在迁移学习场景下,需要把分类层的初始学习率调大,而骨干网络的学习率调小。做法是让优化器对不同的参数组使用不同的学习率:backbone_params = list(model.backbone.parameters()) head_params = list(model.backbone.fc.parameters()) optimizer = torch.optim.Adam([ {'params': [p for p in backbone_params if p not in head_params], 'lr': 1e-4}, {'params': head_params, 'lr': 1e-3} ])这种分层学习率策略是迁移学习训练的关键细节,能避免随机初始化的分类头与预训练骨干网络训练速度不匹配的问题。4.3 GPU 显存溢出导致训练中断训练过程中报CUDA out of memory是最让人头疼的问题之一。这个错误的根源通常不在于单张图片太大,而在于batch_size、输入尺寸和网络深度三者叠加导致中间特征图占用过多显存。ResNet50 在输入尺寸 224 时,一个 batch 为 64 的情况下大约占 7GB 显存,消费级显卡很容易爆。解决思路按优先级排列:先把batch_size从 64 减半到 32,这一步能立刻释放一半显存;如果还溢出,把输入尺寸从 224 降到 192,但这会影响准确率,尽量避免;终极手段是使用梯度累积,把多次小 batch 的梯度累加后再更新参数,效果和训练大 batch 相近。accumulation_steps = 4 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): outputs = model(images) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意这里把 loss 除以accumulation_steps,因为梯度累积时会累计多个 batch 的梯度,如果直接累加会导致实际学习率相当于原来的 4 倍。4.4 模型预测结果总是偏向训练样本最多的类别训练完成后测试,发现不管输入什么样的图片,模型都倾向于预测成数据量最大的那个类别。这几乎可以断定是类别不均衡导致的。食物数据集的采集天然存在偏差,比如辣子鸡丁的图片可能有上千张,而某些地方小吃可能只有几十张。先统计每个类别的图片数量,用饼图可视化。如果数量差距超过 3 倍,就要采取处理措施。简单有效的方案是数据增强加权,对图片数较少的类别在训练时做更强的增强,相当于虚拟增加样本数。更直接的方式是修改DataLoader中的sampler,使用WeightedRandomSampler,让每个类别被抽到的概率均衡。权重计算公式为:from torch.utils.data import WeightedRandomSampler import numpy as np counts = [len(os.listdir(os.path.join(train_dir, cls))) for cls in class_names] weights = [1.0 / count for count in counts] sample_weights = [weights[label] for _, label in train_dataset.images] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True)每个样本的权重是它的类别样本数的倒数,这样少数类别的样本被抽中的概率相对更高。replacement=True表示允许同一个样本在同一个 epoch 中被多次抽中,这对小样本类别的训练效果提升比较明显。5. 二次开发思路:把模型改造成适合自己的菜品识别工具5.1 换成自己的数据集:只改三个文件拿到这份项目源码后,最常见的二次开发需求是替换成自己的数据集。假设你想做一个地方特色菜品识别系统,操作路径是这样的:把新图片按照类别分文件夹存放,放到data/train和data/val下,然后修改config.py中的NUM_CLASSES、TRAIN_DIR、VAL_DIR三个配置项即可。dataset.py会自动扫描新文件夹结构,根据子文件夹名生成类别列表,不需要手动维护类别映射文件。训练时需要注意一个细节:新的数据集规模如果比较小,比如每个类别只有一两百张图片,训练轮数要适当减少,同时把全连接层之前的 Dropout 概率从 0.2 提高到 0.5。小数据集天然更容易过拟合,加强 Dropout 是最经济的正则化手段。如果团队里有标注人力,再给数据集补充几百张典型角度的图片,效果会更明显。5.2 部署到 Web 服务:把推理流程封装成接口想把这套模型变成可用的 Web 服务,不需要大改代码,只要把infer.py中的预测逻辑封装成一个函数,通过 Web 框架暴露 HTTP 接口即可。关键瓶颈不在模型推理本身,而在于图片预处理和模型加载的时机。模型权重文件应该在服务启动时加载一次到内存,而不是每次请求时重新torch.load,否则并发稍微上来一点,响应延迟就会飙升。推理时还有个容易被忽略的性能点:T.Resize(256)和T.CenterCrop(224)对单张图片的耗时大约几十毫秒,比 GPU 推理本身还慢。可以先把图片在内存中解码成 RGB 数组,把缩放和裁剪操作放到 GPU 上通过张量操作完成,整体延迟能减少三成左右。这类优化属于锦上添花,项目先跑通再考虑也不迟。5.3 可视化学习:画出卷积核关注区域如果你想进一步理解模型到底学到了什么视觉特征,可以接入 Grad-CAM,生成图片热力图,直观看到模型把注意力放在菜品的哪个区域。做法是在model.py中注册forward hook获取最后一层卷积层的特征图,然后用其权重对通道求和,得到与输入图片尺寸一致的热力图,叠加显示在原始图片上。这一步不需要重新训练模型,直接用已保存的权重即可。我最初拆这个项目的代码时,也遇到了验证集准确率上不去、显存溢出各种翻车场景,后来每次换数据集都强制走一遍“先统计类别分布、再检查数据泄漏、再确认模型预训练权重加载正确”的流程,踩坑的概率就小了很多。希望这份拆解能帮你少走一些弯路,把项目顺利跑起来。本文还有配套的精品资源,点击获取
简介:基于卷积神经网络的食物图像识别项目完整代码与数据包,面向具备Python基础并希望实践CNN图像分类的开发者与学习者。资源围绕TensorFlow与tf.keras构建食物识别模型,涵盖数据预处理、网络定义、训练评估与预测全流程,并引入迁移学习思路供进阶参考。压缩包共32个文件,约17.28MB,其中包含12个Python源码(模型构建、训练、测试及图像转换)、训练集TFRecords、模型权重,以及项目配置与说明文档,目录结构清晰便于直接运行和二次开发。已有6701人学习下载,尤其适合用作物联网、AI课程设计、毕业设计或入门深度学习的实操素材。通过运行资源包,读者可复现从原始图片到食物类别预测的完整流程,理解卷积层、池化层与全连接层的作用,并在真实数据集上观察模型调参效果。1. 基于卷积神经网络的食物图像识别:拍一张照就能判断菜品的完整实践这份资源做的是“拍一张菜品照片,几秒内输出它属于哪个类别”的图像识别项目。和手写数字、猫狗分类这类入门 Demo 不同,食物类别之间视觉差异很小,比如不同做法的土豆、相近颜色的汤羹,模型很容易翻车。项目用卷积神经网络搭建了一套从数据集预处理、模型训练到单张图片预测的完整流程,适配消费级显卡就能跑,适合想从分类任务过渡到真实场景识别的开发者。如果你是第一次接触图像识别,或者已经跑通过几个 MNIST 项目、想看看更高难度分类任务怎么做,这份资源可以直接照着复现。2. 架构与技术选型:为什么食物识别要这样搭模型2.1 食物识别的难点决定了模型结构刚开始接触这类项目时,我习惯直接用现成的分类网络跑一遍。但食物图像识别有一个显著特点:类间差异小、类内差异大。同样是“土豆”这个类别,可能包含炒土豆丝、土豆炖牛肉、烤土豆片,外观差异极大;而“糖醋里脊”和“菠萝咕咾肉”从颜色和质感上看又非常接近。这种数据分布对模型的特征提取能力要求更高,单纯堆卷积层数并不能解决全部问题,需要在网络结构上做针对性设计。项目采用的基础骨架是 ResNet。选择 ResNet 而不是 VGG 或 GoogLeNet,原因是残差结构能有效缓解网络加深带来的梯度消失问题。传统 VGG 网络堆到十几层之后,反向传播时梯度很容易衰减,训练收敛非常慢。ResNet 通过跳跃连接把前一层的输出直接加到后一层,让梯度能有一条“高速公路”传回浅层,这样网络就能安全地加深到 50 层以上,而不出现训练不收敛的玄学问题。在 ResNet 基础上,项目做了一个关键调整:把网络末尾的全连接层替换成自定义的分类头。原始 ResNet 在 ImageNet 上训练时输出 1000 类,而这个项目要识别的食物类别只有几十种,所以需要把最后的全连接层改成对应数量的输出。另外,在全局平均池化之后接了一个 Dropout 层,概率设为 0.2,用来抑制过拟合。食物数据集的样本量通常不大,如果不加 Dropout,训练集准确率很容易冲到 99%,但验证集表现平平。2.2 项目目录结构与数据流动拿到项目源码后,第一件事不是急着跑训练,而是把目录结构理清楚。常见的图像分类项目会有模型定义、数据加载、训练脚本、推理脚本、工具函数这几个模块分开存放,这份资源也不例外:food-classifier/ ├── config.py # 全局配置:路径、超参数、类别数 ├── dataset.py # 数据加载与增强 ├── model.py # 卷积神经网络结构定义 ├── train.py # 训练主流程 ├── infer.py # 单张图片推理 ├── utils.py # 工具函数:日志、模型保存 ├── requirements.txt # 依赖列表 ├── data/ │ ├── train/ # 训练集,按类别分文件夹 │ ├── val/ # 验证集 │ └── test/ # 测试集 └── checkpoints/ # 保存训练好的模型权重数据流是这样走的:训练脚本从config.py读取超参数,然后调用dataset.py中的 DataLoader 加载图片,经过随机裁剪、翻转、归一化等预处理后,逐批送入model.py定义的卷积神经网络。前向传播算出预测结果,与真实标签计算交叉熵损失,反向传播更新权重。每个 epoch 结束后在验证集上评估准确率,并把最优权重保存到checkpoints目录。推理时,infer.py加载保存的权重,对单张图片走同样的预处理流程,最终输出每个类别的概率分布。config.py是整个项目的配置中心,所有可调的参数都集中在这里。学习率、批量大小、训练轮数、输入图片尺寸、类别列表、模型保存路径,都会在这个文件里定义。实际改造项目时,先改这个文件就能跑通大半流程,不用到处翻代码找参数。3. 核心模块拆解:一张菜品图片从加载到输出概率的完整链路3.1 数据加载与增强模块食物识别项目里,数据质量直接决定模型的最终效果。dataset.py文件实现了一个继承自 PyTorchDataset类的自定义数据集,核心逻辑是读取图片文件、做数据增强、返回张量。代码结构大致是这样的:import os import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class FoodDataset(Dataset): def __init__(self, root_dir, transform=None): self.root_dir = root_dir self.transform = transform self.classes = sorted(os.listdir(root_dir)) self.class_to_idx = {cls: idx for idx, cls in enumerate(self.classes)} self.images = [] for cls in self.classes: cls_dir = os.path.join(root_dir, cls) for img_name in os.listdir(cls_dir): if img_name.endswith(('.jpg', '.png', '.jpeg')): self.images.append((os.path.join(cls_dir, img_name), self.class_to_idx[cls])) def __len__(self): return len(self.images) def __getitem__(self, idx): img_path, label = self.images[idx] image = Image.open(img_path).convert('RGB') if self.transform: image = self.transform(image) return image, label这段代码的核心逻辑不复杂:初始化时扫描根目录下的子文件夹,把每个子文件夹名当作一个类别,文件夹内的每张图片对应一个标签。__getitem__方法按索引返回一个预处理后的图片张量和它的类别索引。值得注意的一个细节是Image.open(img_path).convert('RGB')。如果训练集中混入了灰度图或 RGBA 格式的 PNG 图,不统一转换成 RGB,训练时会在数据送入网络的那一步报通道数不匹配的错误。这个转换操作虽然消耗一点时间,但能避免后面排查数据格式兼容性问题。数据增强部分,项目用的是 PyTorch 官方的torchvision.transforms组合,常见的配置是:train_transform = T.Compose([ T.RandomResizedCrop(224, scale=(0.6, 1.0)), T.RandomHorizontalFlip(), T.RandomRotation(15), T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = 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]) ])训练集和验证集用不同的预处理策略。训练集用了随机裁剪、水平翻转、随机旋转和颜色抖动,目的是让模型看到同一道菜的不同角度、不同亮度表现,增强泛化能力。验证集不做随机增强,只做缩放和中心裁剪,保证评估结果的稳定性。这里的Normalize参数用的是 ImageNet 数据集的均值和标准差,因为 ResNet 预训练权重是在 ImageNet 上生成的,如果换一套归一化参数,模型加载预训练权重后特征分布就不匹配了。3.2 卷积神经网络模型定义model.py中定义了整个网络结构。项目没有从零手写每一个卷积层,而是使用 PyTorch 自带的torchvision.models加载 ResNet 预训练模型,再修改分类头。这种迁移学习的做法在食物识别这类中规模数据集上非常有效,因为底层卷积核学到的边缘、纹理特征在通用图像上已经足够好,只需要重新训练高层分类器即可。import torch.nn as nn import torchvision.models as models class FoodClassifier(nn.Module): def __init__(self, num_classes, model_name='resnet50', pretrained=True): super(FoodClassifier, self).__init__() if model_name == 'resnet50': self.backbone = models.resnet50(pretrained=pretrained) elif model_name == 'resnet18': self.backbone = models.resnet18(pretrained=pretrained) else: raise ValueError(f"Unsupported model: {model_name}") in_features = self.backbone.fc.in_features self.backbone.fc = nn.Sequential( nn.Dropout(0.2), nn.Linear(in_features, num_classes) ) def forward(self, x): return self.backbone(x)把backbone.fc整个替换成新的Sequential模块,是迁移学习的标准操作。in_features从原始全连接层中读取,这样不管换成 ResNet18 还是其他 ResNet 变体,代码都能自动适配输入维度。Dropout 加在Linear前面,注意这里的 Dropout 在训练和推理时会自动切换行为,训练时随机丢弃部分神经元,推理时保留全部,这是 PyTorch 内部处理好的,不需要手动关闭。这里有一个实际训练时需要做的选择:如果计算资源有限,用resnet18就够了,训练速度快,内存占用低;如果追求更高的准确率,用resnet50能多出约 2 到 3 个百分点。项目里默认配置的是resnet50,但也可以改配置直接切换到小模型,不用修改其他代码。前向传播非常简单,直接把输入张量传给 backbone 即可。ResNet 的前几层自己会做卷积、批归一化、ReLU 激活、残差相加的操作,对外部调用者完全透明。这种封装方式也方便后续接入 TensorBoard 观察中间层特征图,只要在forward方法里加一个 hook 就能拿到指定层的输出。3.3 训练主循环与评估逻辑训练逻辑集中在train.py中,主要步骤包括:实例化数据集和数据加载器、定义损失函数与优化器、循环迭代训练、在每个 epoch 结束后评估验证集、保存最优模型。核心训练循环代码如下:import torch import torch.nn as nn from torch.utils.data import DataLoader from dataset import FoodDataset from model import FoodClassifier import config train_dataset = FoodDataset(config.TRAIN_DIR, transform=train_transform) val_dataset = FoodDataset(config.VAL_DIR, transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=config.BATCH_SIZE, shuffle=True, num_workers=config.NUM_WORKERS) val_loader = DataLoader(val_dataset, batch_size=config.BATCH_SIZE, shuffle=False, num_workers=config.NUM_WORKERS) model = FoodClassifier(num_classes=config.NUM_CLASSES, pretrained=True) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=config.LR) def train_one_epoch(epoch): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / total accuracy = correct / total print(f'Epoch {epoch} | Train Loss {avg_loss:.4f} | Acc {accuracy:.4f}') def evaluate(): model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return correct / total每次迭代先调用optimizer.zero_grad()清空上一步的梯度,然后前向传播计算输出,用交叉熵损失计算出 loss,调用loss.backward()反传梯度,最后optimizer.step()更新参数。这个顺序是 PyTorch 的标准流程,如果漏掉zero_grad(),梯度会在多次迭代中叠加,训练结果会出现异常波动。验证集评估时一定要加torch.no_grad()和model.eval()。model.eval()会切换批归一化层和 Dropout 层的行为,让它们使用固定的统计量而不是当前批次的数据。如果不加这一行,验证集准确率会有明显波动,看起来像是模型不稳定,实际上是评估模式没有切换。交叉熵损失在这里直接处理了 Softmax。nn.CrossEntropyLoss内部已经把 Softmax 和负对数似然合并在一起,所以模型输出的原始 logits 直接传给损失函数就行,不需要在模型末尾额外加 Softmax 层。只有推理预测时,才对输出做torch.softmax得到概率分布。优化器选择上,项目使用 Adam。相比 SGD,Adam 自带自适应学习率,对学习率的选择没那么敏感,默认学习率 0.001 通常就能收敛。但如果想让效果更上一层楼,可以换成带动量的 SGD,配合余弦退火学习率调度,训练后期收敛更平稳。两种优化器各有优点,食物识别这类任务 Adam 是更稳妥的起点。3.4 推理流程与概率输出infer.py文件实现了加载模型权重并对单张图片进行预测的功能。推理和训练最大的不同在于,不需要计算梯度,也不需要 batch 的标签,只要把图片预处理成和训练时相同尺寸、相同张量格式,通过网络得到输出,再转成可读的类别名。import torch from PIL import Image from model import FoodClassifier from dataset import FoodDataset import torchvision.transforms as T def predict_image(model, image_path, class_names): transform = 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]) ]) image = Image.open(image_path).convert('RGB') input_tensor = transform(image).unsqueeze(0) model.eval() with torch.no_grad(): output = model(input_tensor) probabilities = torch.softmax(output, dim=1) top_prob, top_class = torch.max(probabilities, 1) return class_names[top_class.item()], top_prob.item()推理时给输入张量加了一个unsqueeze(0),这是因为模型期望的输入维度是(batch_size, channels, height, width)。单张图片没有 batch 维度,所以需要手动加一维。如果漏掉这一步,PyTorch 会报维度不匹配的错误。torch.softmax(output, dim=1)把网络输出的 logits 转换成概率,torch.max取出最大值对应的索引。用top_prob.item()把张量转成 Python 浮点数,方便后续展示。class_names 可以从训练集的类别文件夹顺序获取,注意训练时dataset.py中sorted(os.listdir())对类别进行了排序,推理时也要保持同样的顺序,否则概率索引对应的类别名就会对不上。4. 避坑指南:食物识别训练与部署的常见问题排查4.1 训练集准确率接近 100%,验证集却持续偏低这个现象在食物识别项目中频繁出现,特征很明显:训练 loss 一路降到接近 0,验证集准确率却只到 70% 左右就停滞了。原因基本可以锁定在两个方向:一是数据泄漏,二是过拟合。数据泄漏在食物项目的具体表现为,同一个菜品被重复拍摄后被同时分入训练集和验证集,或者数据增强方式太弱导致模型记住了训练样本。排查方法是随机抽几对训练集和验证集图片,人工对比是否有相同的图片。如果是重复数据,重新划分数据集,确保同源图片隔离。过拟合方面,先检查config.py中的 Dropout 概率是否生效。有些时候模型文件里虽然写了 Dropout 层,但加载的预训练权重把全连接层整个覆盖了,导致新加的 Dropout 没有实际参与前向传播。确认方式是打印模型结构看fc层的子模块列表。解决思路是增加数据增强的强度,比如把RandomResizedCrop的 scale 范围从(0.6, 1.0)改成(0.4, 1.0),让裁剪更多样。4.2 训练 loss 不下降或震荡剧烈训练刚启动时 loss 一直保持在 4.5 左右,过了几个 epoch 也没有明显下降趋势,这种状况通常不是模型结构问题,而是超参数设置不合理。对于 30 类左右的分类任务,随机初始化的交叉熵损失大约是log(1/30)即 3.4 左右,如果初始 loss 远高于这个值,说明模型输出被错误地初始化了。常见的原因是加载预训练权重后,新替换的全连接层没有做合适的初始化。PyTorch 的nn.Linear默认会做 Kaiming 初始化,但这里的问题在于最终的分类层直接使用随机初始化,在迁移学习场景下,需要把分类层的初始学习率调大,而骨干网络的学习率调小。做法是让优化器对不同的参数组使用不同的学习率:backbone_params = list(model.backbone.parameters()) head_params = list(model.backbone.fc.parameters()) optimizer = torch.optim.Adam([ {'params': [p for p in backbone_params if p not in head_params], 'lr': 1e-4}, {'params': head_params, 'lr': 1e-3} ])这种分层学习率策略是迁移学习训练的关键细节,能避免随机初始化的分类头与预训练骨干网络训练速度不匹配的问题。4.3 GPU 显存溢出导致训练中断训练过程中报CUDA out of memory是最让人头疼的问题之一。这个错误的根源通常不在于单张图片太大,而在于batch_size、输入尺寸和网络深度三者叠加导致中间特征图占用过多显存。ResNet50 在输入尺寸 224 时,一个 batch 为 64 的情况下大约占 7GB 显存,消费级显卡很容易爆。解决思路按优先级排列:先把batch_size从 64 减半到 32,这一步能立刻释放一半显存;如果还溢出,把输入尺寸从 224 降到 192,但这会影响准确率,尽量避免;终极手段是使用梯度累积,把多次小 batch 的梯度累加后再更新参数,效果和训练大 batch 相近。accumulation_steps = 4 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): outputs = model(images) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意这里把 loss 除以accumulation_steps,因为梯度累积时会累计多个 batch 的梯度,如果直接累加会导致实际学习率相当于原来的 4 倍。4.4 模型预测结果总是偏向训练样本最多的类别训练完成后测试,发现不管输入什么样的图片,模型都倾向于预测成数据量最大的那个类别。这几乎可以断定是类别不均衡导致的。食物数据集的采集天然存在偏差,比如辣子鸡丁的图片可能有上千张,而某些地方小吃可能只有几十张。先统计每个类别的图片数量,用饼图可视化。如果数量差距超过 3 倍,就要采取处理措施。简单有效的方案是数据增强加权,对图片数较少的类别在训练时做更强的增强,相当于虚拟增加样本数。更直接的方式是修改DataLoader中的sampler,使用WeightedRandomSampler,让每个类别被抽到的概率均衡。权重计算公式为:from torch.utils.data import WeightedRandomSampler import numpy as np counts = [len(os.listdir(os.path.join(train_dir, cls))) for cls in class_names] weights = [1.0 / count for count in counts] sample_weights = [weights[label] for _, label in train_dataset.images] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True)每个样本的权重是它的类别样本数的倒数,这样少数类别的样本被抽中的概率相对更高。replacement=True表示允许同一个样本在同一个 epoch 中被多次抽中,这对小样本类别的训练效果提升比较明显。5. 二次开发思路:把模型改造成适合自己的菜品识别工具5.1 换成自己的数据集:只改三个文件拿到这份项目源码后,最常见的二次开发需求是替换成自己的数据集。假设你想做一个地方特色菜品识别系统,操作路径是这样的:把新图片按照类别分文件夹存放,放到data/train和data/val下,然后修改config.py中的NUM_CLASSES、TRAIN_DIR、VAL_DIR三个配置项即可。dataset.py会自动扫描新文件夹结构,根据子文件夹名生成类别列表,不需要手动维护类别映射文件。训练时需要注意一个细节:新的数据集规模如果比较小,比如每个类别只有一两百张图片,训练轮数要适当减少,同时把全连接层之前的 Dropout 概率从 0.2 提高到 0.5。小数据集天然更容易过拟合,加强 Dropout 是最经济的正则化手段。如果团队里有标注人力,再给数据集补充几百张典型角度的图片,效果会更明显。5.2 部署到 Web 服务:把推理流程封装成接口想把这套模型变成可用的 Web 服务,不需要大改代码,只要把infer.py中的预测逻辑封装成一个函数,通过 Web 框架暴露 HTTP 接口即可。关键瓶颈不在模型推理本身,而在于图片预处理和模型加载的时机。模型权重文件应该在服务启动时加载一次到内存,而不是每次请求时重新torch.load,否则并发稍微上来一点,响应延迟就会飙升。推理时还有个容易被忽略的性能点:T.Resize(256)和T.CenterCrop(224)对单张图片的耗时大约几十毫秒,比 GPU 推理本身还慢。可以先把图片在内存中解码成 RGB 数组,把缩放和裁剪操作放到 GPU 上通过张量操作完成,整体延迟能减少三成左右。这类优化属于锦上添花,项目先跑通再考虑也不迟。5.3 可视化学习:画出卷积核关注区域如果你想进一步理解模型到底学到了什么视觉特征,可以接入 Grad-CAM,生成图片热力图,直观看到模型把注意力放在菜品的哪个区域。做法是在model.py中注册forward hook获取最后一层卷积层的特征图,然后用其权重对通道求和,得到与输入图片尺寸一致的热力图,叠加显示在原始图片上。这一步不需要重新训练模型,直接用已保存的权重即可。我最初拆这个项目的代码时,也遇到了验证集准确率上不去、显存溢出各种翻车场景,后来每次换数据集都强制走一遍“先统计类别分布、再检查数据泄漏、再确认模型预训练权重加载正确”的流程,踩坑的概率就小了很多。希望这份拆解能帮你少走一些弯路,把项目顺利跑起来。本文还有配套的精品资源,点击获取