ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用深度学习实现仿真蛋糕识别:PyTorch图像分类实战

用深度学习实现仿真蛋糕识别:PyTorch图像分类实战

最近总在短视频和朋友圈刷到“你以为是一个游戏机,其实是个蛋糕”“你以为是一双球鞋,结果切开是奶油”之类的仿真蛋糕挑战。每次看到评论区一堆人在吵“这到底是不是蛋糕”,作为一个常年写图像分类项目的程序员,我的第一反应不是馋,而是:能不能用深度学习做一个“Cake or Real”判别器,把真蛋糕和仿真蛋糕自动分类出来?

这篇文章就围绕这个思路展开,把它做成一个完整的图像分类实战项目。适合刚学完 PyTorch、想找一个完整项目练手的读者,也适合想快速落地一个二分类图像任务的开发者。读完你可以掌握:数据集如何整理、迁移学习如何微调、训练脚本怎么写、如何用 Flask 包装成一个可上传图片判别的 Web 服务,以及实际踩坑时怎么排查。

1. Cake or Real 到底是什么,为什么适合做图像分类项目

1.1 这里的“Cake”指的是什么

先做一个概念区分。短视频里的“Cake or Real”,核心指的是仿真蛋糕,也就是用翻糖、糖艺、3D 打印模具等方式做出的“长得像真实物品”的蛋糕。

它的特点很明确:

  • 外观上高度还原某个真实物体,可能是一个苹果、一只球鞋、一个相机镜头;
  • 本质上仍然是可以吃的蛋糕/甜品;
  • 从照片角度看,光线、颜色、纹理都和真实物体非常接近,肉眼经常分不出来。

仿真蛋糕不是新事物。传统食品造型工艺早就存在,但近几年因为短视频传播,这类内容重新火了起来。它天然适合作为图像分类项目的切入点,因为分类边界非常模糊,比“猫 vs 狗”有挑战性得多。

1.2 为什么拿它做图像分类项目

图像分类是深度学习里最基础、最经典的任务。一个“Cake or Real”判别器,本质上就是一个二分类模型:输入一张图片,输出两个概率——它是仿真蛋糕的概率,以及它是真实物品的概率。

但和一般的二分类相比,它有几个值得研究的难点:

  • 类内差异大:仿真蛋糕可以仿冒任何东西,而“真实的物品”也包括一切东西,两类图片内部的视觉差异非常大;
  • 类间相似度高:做得好的仿真蛋糕和真实物体几乎无法用肉眼区分;
  • 环境干扰多:拍摄角度、光线、滤镜、背景都会严重影响分类结果。

这几个特点决定了它不是一个“随机初始化一个 CNN 就能跑出好结果”的简单 Demo,而是需要用到迁移学习、数据增强、训练策略调整等常规工程手段才能做好的项目。

对一个开发者来说,这个项目真正的价值不在于“蛋糕”,而在于你通过一个有趣、有话题度的数据场景,完整走了一遍深度学习图像分类的工程流程。以后换到工业质检、商品识别、医学影像初筛,流程是完全一样的。

2. 环境准备与版本说明

2.1 硬件环境

先说一下硬件。图像分类是一个训练密集型任务,理论上纯 CPU 也可以跑,但速度会比较慢。

GPU:NVIDIA 显卡,建议显存 4GB 以上 内存:16GB 以上(主要看图片分辨率和批量大小) 硬盘:SSD 更好,毕竟要读大量图片

如果你的电脑没有 NVIDIA 显卡,或者显存比较小,不一定代表跑不了。可以把图片分辨率调小、batch size 调小、选用更小的模型,这样 CPU 也能完成训练,只是时间会长一些。本文的代码按照“有 GPU 就优先用 GPU,没有 GPU 就回退 CPU”的思路来写。

2.2 软件环境

关于版本,我不想把某一套固定版本写死。深度学习生态迭代很快,不同显卡驱动、不同 CUDA 版本之间兼容性差异很大。建议使用下面这个组合作为基础:

操作系统:Windows 10/11 或 Ubuntu 20.04/22.04 Python:3.9 或 3.10 PyTorch:2.x(1.x 也可以,代码逻辑兼容) torchvision:与 PyTorch 版本对应 Flask:2.x 或 3.x

如果你是第一次搭建深度学习环境,推荐用 Anaconda 创建独立虚拟环境,避免和系统 Python 环境冲突。

conda create -n cake_or_real python=3.10 conda activate cake_or_real

然后根据你的 CUDA 版本安装 PyTorch。例如:

pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118

如果你没有 NVIDIA GPU,直接安装 CPU 版本:

pip install torch torchvision

最后安装 Flask,用于后面部署推理服务:

pip install flask

需要注意:上面命令里cu118只是一个示例,具体版本要以你本机 CUDA 驱动支持的最高版本为准。先跑nvidia-smi看一下驱动支持的 CUDA 版本,再选择对应的 PyTorch 安装命令。

2.3 项目目录结构

为了让后面的代码不迷路,先定好项目结构:

cake_or_real/ ├── dataset/ │ ├── train/ │ │ ├── cake/ │ │ │ ├── img_001.jpg │ │ │ └── ... │ │ └── real/ │ │ ├── img_001.jpg │ │ └── ... │ └── val/ │ ├── cake/ │ └── real/ ├── checkpoints/ │ └── best_model.pth ├── train.py ├── predict.py └── app.py

dataset目录是训练数据,checkpoints目录保存模型权重,train.py是训练脚本,predict.py是单张图片推理脚本,app.py是 Flask 服务。

3. 数据准备:项目的核心

做图像分类项目,数据整理的优先级永远高于模型调参。很多项目效果差,不是模型不行,是数据太乱。

3.1 数据来源与版权注意事项

目前没有现成的“真蛋糕 vs 仿真蛋糕”公开数据集,所以数据需要自己收集。常见途径包括:

  • 开源数据集网站(如 Kaggle、Open Images)中搜 cake、food、toy 等类别,再人工筛选;
  • 自行拍摄,这是版权最安全、质量最可控的方式;
  • 从公开图库获取授权图片。

在收集图片时,有几点必须注意,尤其在写博客或做产品 Demo 时要格外克制:

  • 不要未经授权批量爬取社交平台图片,尤其是带有用户个人信息的图片;
  • 如果爬取图片,务必遵守目标网站的robots.txt约定,控制频率,不绕过登录验证,不窃取非公开内容;
  • 商用前,要逐张确认图片的使用许可,避免版权纠纷;
  • 训练数据里不要出现可识别的个人面部信息,不做人脸相关用途。

一个相对稳妥的策略:从开放许可的图片数据集中先筛出一个基础集,然后自己拍几百张补充边缘场景,比如不同光线下的仿真蛋糕、切开后的蛋糕、放倒的球鞋蛋糕。

3.2 数据集目录组织

PyTorch 的torchvision.datasets.ImageFolder可以直接按照目录结构读取数据。目录结构就是:

dataset/ ├── train/ │ ├── cake/ # 仿真蛋糕图片 │ └── real/ # 真实物品图片 └── val/ ├── cake/ └── real/

类别名称建议只用小写英文。这样ImageFolder会自动把cake映射为标签 0,把real映射为标签 1,不用手动维护 label 表。

图片数量建议:

  • 训练集每类至少 300 张,越多越好;
  • 验证集每类 50 张以上;
  • 图片统一缩放到224x224,JPEG 或 PNG 都行。

3.3 数据集增强策略

样本不够时,数据增强是防止过拟合最直接的手段。在训练阶段,可以对图片做随机变换;在验证阶段,不做数据增强,只做标准化。

常用增强组合:

  • 随机水平翻转;
  • 随机旋转 15 度以内;
  • 随机改变亮度、对比度、饱和度;
  • 随机裁剪;
  • 可选:CutMix、MixUp 等高级增强。

要注意:仿真蛋糕和真实物品的分类非常依赖纹理细节,过度地做色彩抖动也可能让模型忽略掉“奶油质感”和“塑料质感”的差异。增强力度需要根据训练效果动态调整,不要上来就全开。

4. 构建模型:迁移学习是捷径

4.1 为什么不用自己从零训练一个 CNN

从零训练一个深度卷积网络,往往需要几十万张图片和很长的时间,对个人和中小团队并不现实。

迁移学习的思路是:先把一个在大规模数据集上训练好的模型拿过来,它已经学会了通用的边缘、纹理、形状特征;然后在它基础上,用你的小数据集微调最后几层。

对“Cake or Real”这种二分类任务,常用的预训练模型包括:

  • ResNet18 / ResNet34:体积小,速度快,适合起步;
  • ResNet50:表达能力更强,但对显存要求更高;
  • EfficientNet-B0:在分类精度和计算量之间平衡得比较好;
  • MobileNetV3:适合后续部署到移动端或边缘设备。

掌握思路比纠结选哪个模型更重要。建议第一版先用 ResNet18,跑通了再换更强的模型验证效果。

4.2 加载预训练模型并修改分类头

在 PyTorch 里,只需要改最后一层全连接即可。比如 ResNet18 原始输出是 1000 类,我们要改成 2 类:

# 文件路径:train.py import torchvision.models as models import torch.nn as nn model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # 获取 ResNet 最后一个全连接层的输入维度 num_features = model.fc.in_features # 将全连接层替换成 2 分类输出 model.fc = nn.Linear(num_features, 2)

这里weights=models.ResNet18_Weights.DEFAULT会自动下载 ImageNet 预训练权重。如果你的网络不好下载,可以先手动下载权重文件,再用torch.load加载。

4.3 完整训练脚本

下面给出一套可以完整运行的训练脚本。代码中的注释已经写清楚了每一步的作用。

# 文件路径:train.py import os import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms, models # ------------------------------ # 1. 数据预处理与增强 # ------------------------------ train_transforms = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transforms = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder(root='dataset/train', transform=train_transforms) val_dataset = datasets.ImageFolder(root='dataset/val', transform=val_transforms) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) # ------------------------------ # 2. 模型准备 # ------------------------------ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print("使用设备:", device) model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 2) model = model.to(device) # ------------------------------ # 3. 损失函数与优化器 # ------------------------------ criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1) # ------------------------------ # 4. 训练与验证 # ------------------------------ num_epochs = 15 best_acc = 0.0 for epoch in range(num_epochs): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() train_loss = running_loss / total train_acc = 100.0 * correct / total # 验证 model.eval() val_correct = 0 val_total = 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) val_total += labels.size(0) val_correct += (predicted == labels).sum().item() val_acc = 100.0 * val_correct / val_total print(f"Epoch [{epoch+1}/{num_epochs}] " f"Loss: {train_loss:.4f} | Train Acc: {train_acc:.2f}% | Val Acc: {val_acc:.2f}%") scheduler.step() # 保存验证集准确率最高的模型 if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'checkpoints/best_model.pth') print(f"保存最佳模型,Val Acc: {val_acc:.2f}%")

脚本里有一个非常关键的细节:验证集必须关闭梯度,也就是torch.no_grad(),否则会大量占用显存,也影响速度。

另外,我们按照“验证集准确率最高”来保存模型,而不是保存最后一个 epoch 的模型。这样即使训练后期出现过拟合,最终拿到的仍是泛化最好的权重。

5. 训练与评估

5.1 损失函数与优化器选择

二分类任务在 PyTorch 里最常用的损失函数是CrossEntropyLoss。它内部包含了 Softmax 计算,输出层的节点数是 2,label 是整数索引。

优化器方面,Adam 是首选,它对学习率的敏感度比 SGD 低,训练前期收敛快。如果你发现验证集准确率在后期一直震荡,可以换成AdamW,或者切到SGD + Momentum做最后的精调。

学习率建议初始值0.001,如果训练损失下降非常缓慢,可以尝试0.0001;如果损失出现震荡,则调低一点。

5.2 训练参数建议

以下参数仅针对 ResNet18、224x224 输入、几十万张以内的中小数据集给出参考:

batch_size:32 epochs:15 到 30 初始学习率:0.001 学习率衰减:每 5 个 epoch 乘以 0.1

如果显存不足,优先把batch_size降到 16 或 8,而不是缩小图片分辨率。图片分辨率直接关系到模型能不能看清纹理细节。

5.3 评估模型:准确率、混淆矩阵

训练结束后,验证准确率只能说明整体表现。对于二分类,还要看混淆矩阵,搞清楚模型在哪个方向上更容易犯错。

手动在验证集上统计四个值:

  • 真蛋糕识别正确数;
  • 真蛋糕被误判为真实物品数;
  • 真实物品识别正确数;
  • 真实物品被误判为蛋糕数。

通常最容易出现的情况是“仿真蛋糕被误判为真实物品”,尤其是那些高仿球鞋、高仿水果。如果你发现这类误差很多,可以考虑在训练集中补充更多“高仿”案例,而不是盲目增加普通蛋糕图片。

下面给出一个简单的混淆矩阵统计脚本片段:

# 文件路径:train.py 的扩展段 from sklearn.metrics import confusion_matrix all_preds = [] all_labels = [] model.eval() 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) all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds) print("混淆矩阵:") print(cm) # 输出示例(类别顺序:0=cake, 1=real) # [[80 5] # [ 7 68]]

看到类似上面的输出,说明蛋糕类别有 5 张被误判为 real,real 类别有 7 张被误判为 cake。哪个方向的错误影响更大,取决于你的业务目标:你是想更“宽容”地识别出蛋糕,还是更“严格”地区分不是蛋糕的东西。

6. 写一个 Cake or Real 的推理 Demo

训练完模型后,自然要做成能用的 Demo。

6.1 单张图片推理脚本

先写一个predict.py,输入一张图片,输出两个类别的概率。

# 文件路径:predict.py import torch import torch.nn as nn from torchvision import models, transforms from PIL import Image # 类别顺序与训练时的 ImageFolder 完全一致 class_names = ['cake', 'real'] def load_model(): model = models.resnet18(weights=None) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 2) model.load_state_dict(torch.load('checkpoints/best_model.pth', map_location='cpu')) model.eval() return model def predict_image(image_path, model): transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) image = Image.open(image_path).convert('RGB') tensor = transform(image).unsqueeze(0) # tensor shape: (1, 3, 224, 224) with torch.no_grad(): outputs = model(tensor) probabilities = torch.softmax(outputs, dim=1) prob_cake = probabilities[0][0].item() prob_real = probabilities[0][1].item() predicted_idx = probabilities.argmax(dim=1).item() print(f"Cake 概率: {prob_cake:.4f}") print(f"Real 概率: {prob_real:.4f}") print(f"预测结果: {class_names[predicted_idx]}") return class_names[predicted_idx] if __name__ == '__main__': model = load_model() predict_image('test.jpg', model)

注意torch.load时如果模型是用 GPU 训练保存的,在 CPU 环境推理时要指定map_location='cpu',这是一个非常常见的报错点。

6.2 用 Flask 打包成 Web 服务

如果只是自己用,上面的脚本已经够了。如果要给朋友演示一下“Cake or Real”的效果,用 Flask 搭一个最简 Web 服务是很自然的选择。

# 文件路径:app.py import io from flask import Flask, request, jsonify from PIL import Image import torch import torch.nn as nn from torchvision import models, transforms app = Flask(__name__) # 加载模型 model = models.resnet18(weights=None) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 2) model.load_state_dict(torch.load('checkpoints/best_model.pth', map_location='cpu')) model.eval() class_names = ['cake', 'real'] transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def predict_image_bytes(img_bytes): image = Image.open(io.BytesIO(img_bytes)).convert('RGB') tensor = transform(image).unsqueeze(0) with torch.no_grad(): outputs = model(tensor) probabilities = torch.softmax(outputs, dim=1) prob_cake = probabilities[0][0].item() prob_real = probabilities[0][1].item() predicted_idx = probabilities.argmax(dim=1).item() return { 'prediction': class_names[predicted_idx], 'prob_cake': round(prob_cake, 4), 'prob_real': round(prob_real, 4) } @app.route('/predict', methods=['POST']) def predict(): if 'image' not in request.files: return jsonify({'error': 'no image file'}), 400 file = request.files['image'] img_bytes = file.read() result = predict_image_bytes(img_bytes) return jsonify(result) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)

启动服务:

python app.py

然后用 curl 测试:

curl -X POST -F "image=@test.jpg" http://127.0.0.1:5000/predict

预期返回内容类似:

{ "prediction": "cake", "prob_cake": 0.9821, "prob_real": 0.0179 }

如果prob_cake接近 1,说明模型很确定这是仿真蛋糕;如果两个概率都接近 0.5,说明模型自己也拿不准,这时候可以把图片加入训练集重新迭代。

7. 常见问题与排查思路

图像分类项目的大部分问题,都集中在数据、显存和不收敛这几个方向。

问题现象常见原因解决思路
训练时 Loss 一直是 0.69 左右,准确率也卡住模型没有学到有效区分特征,可能数据路径错误导致类别不平衡,或增强过度先关闭数据增强跑 5 个 epoch,确认模型能拟合训练集
验证准确率高,但新图片预测很差训练集和验证集有数据泄漏,或训练数据过少检查是否有重复图片同时出现在 train 和 val;补充更多场景
显存不足(CUDA out of memory)batch size 太大或图片分辨率太高降低 batch_size,降低 num_workers,必要时用torch.cuda.empty_cache()
推理时报size mismatch错保存模型状态字典时,最后一层是 1000 类,加载时模型结构没有改成 2 类在重新定义模型结构后,再加载 state_dict
训练损失下降,但验证损失上升过拟合增加数据增强、加大 dropout、减少训练轮数或缩小模型
预测结果偏向某一类数据类别不平衡统计 train 目录下两类图片数量,补数据或使用 WeightedRandomSampler

再补充一个新手容易忽略的点:datasets.ImageFolder的类别顺序取决于目录名称的字符串顺序。如果目录命名是cake和real,标签就是 0 和 1;但如果你换了一台电脑重新整理数据,目录顺序变了,推理脚本里的class_names就要同步跟着调整,否则类别会错位。

8. 最佳实践与工程建议

8.1 数据管理是项目生命线

在做这类图像分类项目时,建议从一开始就维护一份数据清单,记录每批图片的来源、采集时间、是否做了增强、是否有明显噪声。这看起来麻烦,但在后续追查模型为什么在某个场景失手时,能省下大量时间。

8.2 保存模型时附带元信息

只保存一个.pth权重文件是不够的。强烈建议在checkpoints目录下额外保存一份训练配置:

checkpoints/ ├── best_model.pth └── info.json

info.json里至少包含:

  • 模型的网络结构名称;
  • 输入图片分辨率;
  • 类别顺序;
  • 训练集图片数量;
  • 最终验证集准确率;
  • PyTorch 版本和 torchvision 版本。

这样几个月后再拿这个模型出来推理,或是在另一台机器上复现,都不会因为缺失信息而重新猜配置。

8.3 生产环境要注意数据分布漂移

训练集里的图片和用户真实上传的图片,分布往往不一样。比如训练集里的仿真蛋糕大多是室内的摆拍,用户上传的可能是加了滤镜的视频截图。

如果打算把这个 Demo 做成长期服务,建议在生产环境中记录预测结果的概率分布,定期检查模型是否出现“置信度越来越低”或“某一类占比突然异常”的情况。一旦发现,就需要补充新数据、重新训练,而不是放任模型在旧知识上继续工作。

8.4 安全与合规

最后再强调一遍:收集训练图片时,不要为了省事去爬取社交平台上的用户内容。这类行为既涉及版权问题,也可能违反平台服务条款。使用开源数据集前,要仔细阅读数据集许可证,确认是否允许商用。如果你的项目将来要发布成公开服务,数据合规会比模型精度更容易成为项目停摆的原因。

9. 总结与下一步

这篇文章把一个“仿真蛋糕 vs 真实物品”的娱乐化话题,变成了一套完整的深度学习图像分类流程:从数据整理、迁移学习、训练评估,到 Flask 推理部署,每一步都给了可复制的代码。

你可以做的事非常多:增强模型体积、尝试数据清洗、在推理端加上“高置信度才输出结果”的判断,甚至把它扩展到更多类别,比如“仿真水果 vs 真水果”“塑料玩具 vs 真实物件”。

下次看到仿真蛋糕挑战视频时,不妨先拿这个模型猜一猜,顺便检查一遍自己的图像分类工程能力是否还手熟。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进