1. 项目概述:食品分类的深度学习实践
在计算机视觉领域,图像分类一直是最基础也最具实用价值的任务之一。我最近完成了一个基于卷积神经网络(CNN)的食品分类项目,能够准确识别11类常见食物。这个项目最初源于一个实际需求:餐饮连锁企业需要自动化处理顾客上传的食物照片,用于营养分析和点餐推荐。
食品分类看似简单,实则充满挑战。不同菜系间的相似性(如中式点心和西式甜点)、同一菜品在不同拍摄条件下的外观差异、以及餐具和背景的干扰,都让传统图像处理方法难以应对。而CNN凭借其局部感知和层次化特征提取的能力,特别适合处理这类问题。
这个项目从数据收集到模型部署共耗时6周,最终在测试集上达到了92.3%的准确率。下面我将详细分享整个实现过程,包括数据处理技巧、模型架构选择和调优经验,这些都是我在实际项目中踩过坑后才积累的实战心得。
2. 核心需求与技术选型
2.1 问题定义与业务场景
我们需要建立一个能区分11类食品的智能系统,具体类别包括:汉堡、披萨、寿司、沙拉、牛排、意面、煎饺、炒饭、汤品、甜点和水果。这些类别覆盖了快餐店最常见的食品类型,实际应用场景包括:
- 自动菜单生成:根据顾客拍摄的照片推荐相似菜品
- 营养分析:快速估算食物热量和营养成分
- 餐饮管理:统计各类食品的消费频次
提示:选择食品类别时,建议考虑业务实际需求和数据可获得性的平衡。我们最初设定了20个类别,但发现某些小众食品样本量不足,最终精简为11类。
2.2 技术方案对比
传统图像处理方法(如SIFT特征+SVM分类器)在这个任务上表现不佳,测试准确率仅68%左右。深度学习方案中,我们对比了以下几种架构:
| 方法 | 准确率 | 推理速度 | 模型大小 | 适用性分析 |
|---|---|---|---|---|
| ResNet50 | 89.2% | 45ms | 98MB | 平衡性好,适合中等规模数据 |
| MobileNetV2 | 85.7% | 22ms | 14MB | 移动端友好,牺牲少量精度 |
| EfficientNetB0 | 90.1% | 38ms | 29MB | 效率高但需要更多调参 |
| 自定义CNN | 92.3% | 28ms | 8MB | 针对性设计,需领域知识 |
最终选择自建CNN架构而非预训练模型,主要基于三点考虑:
- 我们的食品图像与ImageNet差异较大,迁移学习优势有限
- 自定义模型可以针对食品特征优化网络结构
- 部署环境对模型大小有严格限制(<10MB)
3. 数据准备与增强策略
3.1 数据收集与标注
我们构建了一个包含35,000张图像的数据集,主要来源包括:
- 公开数据集:Food-101、UEC-Food100
- 餐饮合作伙伴提供的真实顾客照片
- 人工拍摄的补充样本(控制光线和角度)
标注过程特别注意了以下几点:
- 同一菜品在不同角度、光照条件下的多张样本
- 去除含有多个食品类别的复杂场景
- 平衡各类别的样本数量(每类约3,200张)
3.2 数据预处理流程
import cv2 import albumentations as A # 基础预处理 def preprocess(image): # 统一调整为256x256并保持长宽比 transform = A.Compose([ A.LongestMaxSize(max_size=256), A.PadIfNeeded(min_height=256, min_width=256, border_mode=cv2.BORDER_CONSTANT), A.Normalize() ]) return transform(image=image)["image"] # 训练时增强 train_transform = A.Compose([ A.RandomResizedCrop(224, 224), A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(p=0.2), A.Rotate(limit=30), A.CoarseDropout(max_holes=8, max_height=16, max_width=16) ])注意:食品图像增强要符合物理规律。我们避免使用垂直翻转和过度旋转,因为实际中很少会倒置拍摄食物。
3.3 解决类别不平衡的技巧
尽管我们尽量平衡了各类样本,但实际中某些类别(如汉堡)天然更容易获取数据。我们采用了两项对策:
- 样本加权:在损失函数中为稀有类别分配更高权重
class_weights = torch.tensor([1.0, 0.9, 1.2, ..., 1.1]) # 根据样本量倒数设置 criterion = nn.CrossEntropyLoss(weight=class_weights)- 动态过采样:在每个epoch中,对少数类样本进行复制或增强
4. CNN模型架构设计
4.1 网络结构详解
我们的自定义CNN架构如下图所示(实际代码实现):
Input(224x224x3) ↓ ConvBlock(64, kernel=7, stride=2, padding=3) + BN + ReLU ↓ MaxPool(kernel=3, stride=2) ↓ ResidualBlock[128] × 2 ↓ ResidualBlock[256] × 3 ↓ ResidualBlock[512] × 3 ↓ GlobalAveragePooling ↓ Dense(1024) + GELU ↓ Dropout(0.5) ↓ Dense(11) # 输出层关键设计考量:
- 使用较大的初始卷积核(7x7)捕捉食品的整体形态
- 引入残差连接缓解深层网络梯度消失问题
- 中间层使用ReLU,末端使用GELU激活函数
- 全局平均池化替代全连接层减少参数量
4.2 核心组件实现
import torch import torch.nn as nn import torch.nn.functional as F class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride), nn.BatchNorm2d(out_channels)) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) return F.relu(out) class FoodCNN(nn.Module): def __init__(self, num_classes=11): super().__init__() self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3) self.bn1 = nn.BatchNorm2d(64) self.pool = nn.MaxPool2d(kernel_size=3, stride=2) # 残差块组 self.layer1 = self._make_layer(64, 128, 2) self.layer2 = self._make_layer(128, 256, 3, stride=2) self.layer3 = self._make_layer(256, 512, 3, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc1 = nn.Linear(512, 1024) self.dropout = nn.Dropout(0.5) self.fc2 = nn.Linear(1024, num_classes) def _make_layer(self, in_channels, out_channels, blocks, stride=1): layers = [ResidualBlock(in_channels, out_channels, stride)] for _ in range(1, blocks): layers.append(ResidualBlock(out_channels, out_channels)) return nn.Sequential(*layers) def forward(self, x): x = F.relu(self.bn1(self.conv1(x))) x = self.pool(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = F.gelu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x4.3 激活函数选择经验
我们在不同层尝试了多种激活函数:
- 中间层:ReLU计算高效且缓解梯度消失
- 末端全连接层:GELU表现更好,因其平滑特性适合分类任务
实验对比结果:
| 激活函数组合 | 验证准确率 | 训练稳定性 |
|---|---|---|
| 全部ReLU | 91.1% | 偶尔震荡 |
| 全部GELU | 91.8% | 稳定但耗时 |
| ReLU+GELU | 92.3% | 最稳定 |
5. 模型训练与优化
5.1 训练配置
from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model = FoodCNN().to(device) optimizer = AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=20, eta_min=1e-5) criterion = nn.CrossEntropyLoss() # 混合精度训练 scaler = torch.cuda.amp.GradScaler()关键参数选择依据:
- AdamW优化器:比普通Adam更适合视觉任务
- 初始学习率1e-3:通过LR Finder确定
- 余弦退火调度:帮助跳出局部最优
5.2 训练过程监控
我们使用WandB记录以下指标:
- 训练/验证损失
- 类别级精确率、召回率
- 混淆矩阵
- 激活值分布
实操心得:食品分类中,某些类别(如炒饭和煎饺)容易混淆,需要特别关注这些类别对的F1分数变化。
5.3 关键训练技巧
- 渐进式图像尺寸:前5epoch用128x128,后15epoch用224x224
- 标签平滑:设置ε=0.1缓解过拟合
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)- 知识蒸馏:用EfficientNetB4作为教师模型提供软标签
6. 模型评估与部署
6.1 性能指标
在保留测试集(3,500张图)上的表现:
| 指标 | 数值 |
|---|---|
| 总体准确率 | 92.3% |
| 平均精确率 | 91.8% |
| 平均召回率 | 92.1% |
| F1分数 | 91.9% |
| 推理速度 | 28ms (RTX 3060) |
6.2 错误分析
通过混淆矩阵发现主要错误类型:
- 煎饺与锅贴混淆(15%错误)
- 某些沙拉与水果拼盘混淆(12%错误)
- 深色酱汁的意面误判为炒面(9%错误)
解决方案:
- 收集更多易混淆类别的样本
- 添加注意力机制强化局部特征
- 引入多任务学习(同时预测食材)
6.3 部署优化
使用TorchScript导出模型并进行以下优化:
# 模型量化 quantized_model = torch.quantization.quantize_dynamic( model, {nn.Linear}, dtype=torch.qint8) # ONNX导出 torch.onnx.export(model, dummy_input, "food_cls.onnx", opset_version=13)部署后性能:
- 模型大小从23MB压缩到3.7MB
- CPU推理时间从120ms降至45ms
- 内存占用减少60%
7. 常见问题与解决方案
7.1 训练不稳定
现象:损失值剧烈波动,准确率停滞可能原因:
- 学习率过高
- 批次内样本差异过大
- 数据增强过于激进
解决方案:
# 梯度裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # 调整数据增强强度 train_transform = A.Compose([ A.RandomResizedCrop(224, 224, scale=(0.8, 1.0)), # 减小裁剪范围 A.HorizontalFlip(p=0.3), # 降低翻转概率 # 其他增强... ])7.2 类别间混淆严重
现象:某些类别持续相互误判解决方案:
- 特征空间可视化分析
from sklearn.manifold import TSNE features = extract_features(model, dataloader) # 提取倒数第二层特征 tsne = TSNE(n_components=2) vis_features = tsne.fit_transform(features)- 针对性数据增强:对易混淆类别应用特定变换
- 修改损失函数:增加中心损失或三元组损失
7.3 实际部署效果下降
现象:测试集表现良好但真实场景准确率低可能原因:
- 训练数据与真实数据分布差异
- 拍摄条件不一致(光线、角度等)
解决方案:
- 建立持续数据收集管道
- 实现在线学习机制
- 添加预处理模块统一输入分布
# 自动白平衡预处理 def auto_white_balance(image): result = cv2.cvtColor(image, cv2.COLOR_BGR2LAB) avg_a = np.average(result[:, :, 1]) avg_b = np.average(result[:, :, 2]) result[:, :, 1] = result[:, :, 1] - ((avg_a - 128) * 1.1) result[:, :, 2] = result[:, :, 2] - ((avg_b - 128) * 1.1) return cv2.cvtColor(result, cv2.COLOR_LAB2BGR)8. 项目扩展与优化方向
在实际应用中,我们发现以下几个有价值的优化方向:
多模态融合:结合菜品名称文本信息提升准确率
- 使用CLIP等跨模态模型
- 简单实现:将菜名TF-IDF特征与图像特征拼接
细粒度分类:区分同一类别的不同变种
- 如披萨可分为玛格丽特、海鲜、夏威夷等子类
- 需要更精细的标注数据和更深的网络
营养信息预测:扩展模型输出热量和营养成分
# 修改输出层 self.fc_calories = nn.Linear(1024, 1) # 热量预测 self.fc_nutrition = nn.Linear(1024, 5) # 五大营养素异常检测:识别非食品图像或异常菜品
- 使用自动编码器检测分布外样本
- 输出置信度分数过滤低质量预测
边缘设备优化:
- 转换为TensorFlow Lite或Core ML格式
- 实现基于OpenCV的轻量级预处理
// 示例:ARM NEON加速的预处理 void preprocess_neon(cv::Mat &input, float* output) { // NEON指令集优化实现... }
这个食品分类项目从技术验证到生产部署的全过程,让我深刻体会到CNN在实际业务中的应用价值。最大的收获是认识到:在特定领域任务中,经过精心设计的自定义网络往往能超越通用预训练模型,关键是要深入理解业务场景和数据特性。下一步我计划尝试Vision Transformer架构,并引入时序信息处理视频输入,进一步提升系统在动态场景下的表现。