背景:为什么果蔬分类总“翻车”
做毕业设计选“果蔬分类”听起来人畜无害,真正动手才发现坑比果篮还深。
- 公开数据集看似几十万张,实际苹果一个品种就占 30%,香蕉因为表皮反光被标注成三类,类别不平衡到怀疑人生。
- 手机拍照背景五花八门,实验室白桌布却一成不变,结果训练 95% 的准确率一到宿舍阳台就跌到 60%,过拟合得明明白白。
- 导师一句“最好能在树莓派上跑”,直接把 ResNet-152 判了死刑,显存、内存、功耗三座大山压顶。
把这三个痛点记住:数据偏、模型肥、部署难,下面所有操作都围着它们打。
技术选型:ResNet、MobileNet、EfficientNet 怎么挑
毕业设计不是发论文,指标只要“够用 + 能跑”。我用 1.5 万张平衡后的 Fruits-360 子集,在同一台 Jetson Nano 上测了三代主流 CNN,结论直接放表:
| 骨干网络 | Top-1 准确率 | 参数量 | 推理耗时 (ms) | 显存占用 (MB) |
|---|---|---|---|---|
| ResNet-50 | 97.8 % | 25.6 M | 78 | 320 |
| MobileNetV3-L | 96.4 % | 5.4 M | 28 | 120 |
| EfficientNet-B0 | 97.2 % | 5.3 M | 42 | 140 |
- 如果实验室 GPU 管够,ResNet-50 最省心,代码一把梭。
- 要放在树莓派 4B 上现场演示,MobileNetV3 延迟直接砍半,观众不卡顿。
- EfficientNet-B0 精度最高,但通道注意力在 CPU 上开销大,适合板子带 NPU 的场景。
我最终选 MobileNetV3,理由:毕业答辩现场只给 5V-2A 供电,热量一高就降频,速度比绝对精度更重要。
核心实现:PyTorch 训练脚本拆给你看
下面代码全部单文件可跑通,Python 3.9 + PyTorch 2.0,CPU 也能先跑通流程。
数据集用 Fruits-360(Kaggle 公开),目录结构按train/Apple/、test/Banana/放好即可。
- 依赖与超参
import torch, torch.nn as nn, torch.optim as optim from torchvision import datasets, transforms, models from torch.utils.data import DataLoader import os, time, copy DATA_DIR = "data/fruits-360" # 指向解压目录 BATCH = 64 EPOCHS = 25 INPUT_SIZE = 224 NUM_CLS = len(os.listdir(os.path.join(DATA_DIR, "Training"))) # 动态算类别 DEVICE = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")- 数据增强 + 归一化
train_tf = transforms.Compose([ transforms.RandomResizedCrop(INPUT_SIZE), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2, 0.1), # 亮度/对比度/饱和度/色相 transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(INPUT_SIZE), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])- 加载数据集
image_datasets = { x: datasets.ImageFolder(os.path.join(DATA_DIR, x), transform=train_tf if x=="Training" else val_tf) for x in ["Training", "Test"] } dataloaders = {x: DataLoader(image_datasets[x], batch_size=BATCH, shuffle=(x=="Training"), num_workers=4) for x in ["Training", "Test"]}- 迁移学习:锁住骨干,先训分类头
model = models.mobilenet_v3_large(pretrained=True) model.classifier[3] = nn.Linear(model.classifier[3].in_features, NUM_CLS) for param in model.features.parameters(): param.requires_grad = False model = model.to(DEVICE) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.classifier.parameters(), lr=1e-3)- 学习率调度: cosine + 冷启动
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)- 训练循环(精简版)
best_model_wts = copy.deepcopy(model.state_dict()) best_acc = 0.0 for epoch in range(EPOCHS): model.train() running_loss, running_correct = 0.0, 0 for inputs, labels in dataloaders["Training"]: inputs, labels = inputs.to(DEVICE), labels.to(DEVICE) optimizer.zero_grad() with torch.cuda.amp.autocast(enabled=False): # 老显卡可关 outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) running_correct += (outputs.argmax(1) == labels).sum().item() scheduler.step() epoch_acc = running_correct / len(image_datasets["Training"]) print(f"Epoch {epoch+1} loss={running_loss:.3f} acc={epoch_acc:.3f}") # 验证阶段略,acc>best_acc 就保存- 解冻骨干,微调全局
for param in model.features.parameters(): param.requires_grad = True optimizer = optim.Adam(model.parameters(), lr=1e-4) # 更小的 lr # 再跑 10 个 epoch,代码同上跑完 35 epoch,我在 Test 集拿到 96.4 % 准确率,单张 Jetson Nano 推理 28 ms,满足实时。
部署方案:ONNX 导出 + 两条轻量路线
训练机环境再好,答辩现场不一定给你 GPU,模型必须脱离 Python 枷锁。
- 导出 ONNX
dummy = torch.randn(1, 3, 224, 224).to(DEVICE) torch.onnx.export(model, dummy, "fruits_mv3.onnx", input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}}, opset_version=11)2 路线 A:OpenCV DNN 纯 C++ 演示(无依赖)
#include <opencv2/opencv.hpp> int main(){ cv::dnn::Net net = cv::dnn::readNet("fruits_mv3.onnx"); net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV); net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU); cv::Mat img = cv::imread("apple.jpg"), blob; cv::resize(img, img, cv::Size(224,224)); cv::dnn::blobFromImage(img, blob, 1/255.0, cv::Size(), cv::Scalar(0.485,0.456,0.406), true, false); net.setInput(blob); cv::Mat prob = net.forward(); Point classIdPoint; double confidence; minMaxLoc(prob.reshape(1, 1), 0, &confidence, 0, &classIdPoint); printf("pred=%d score=%.3f\n", classIdPoint.x, confidence); }编译后 6 MB 静态可执行文件,树莓派 4B 上 120 ms 跑完,老师直呼“不卡”。
3 路线 B:Flask REST API(Python 但跨平台)
from flask import Flask, request, jsonify import cv2, numpy as np, onnxruntime as ort app = Flask(__name__) ort_sess = ort.InferenceSession("fruits_mv3.onnx") mean = np.array([0.485,0.456,0.406]) std = np.array([0.229,0.224,0.225]) @app.route("/predict", methods=["POST"]) def predict(): file = request.files['image'] img = cv2.imdecode(np.frombuffer(file.read(), np.uint8), cv2.IMREAD_COLOR) img = cv2.resize(img, (224,224)) blob = ((img/255.0) - mean) / std blob = blob.transpose(2,0,1)[None].astype(np.float32) out = ort_sess.run(None, {'input': blob})[0] return jsonify({"label": int(out.argmax()), "score": float(out.max())}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080)Dockerfile 两行就能打包镜像,答辩现场笔记本docker run起服务,前端小程序一扫码,仪式感满满。
性能与安全:让模型经得起“菜市场”考验
实验室白底图好看,真实场景光照、遮挡、阴影一起飞来。我模拟三种“刁难”:
强光过曝:手机开闪光灯直拍,苹果表面一片白。
解决:训练阶段在 ColorJitter 里把亮度上限提到 0.4,并随机加高斯模糊,测试准确率掉 1.2 %,可接受。遮挡:手指捏住梨下半部只剩 40 % 区域。
解决:RandomResizedCrop 的 scale 下限从 0.08 调到 0.4,强迫模型看局部纹理;同时把输入分辨率提到 256×256,推理时再做 CenterCrop,鲁棒性提升 3 %。异常输入:有人上传猫片。
解决:在 Flask 端加 Softmax 阈值,score<0.7 直接返回“未知类别”,并写日志;生产环境再加一层 MobileNet 二分类“果蔬/非果蔬”做网关,减少主模型被调戏的次数。
生产环境避坑:从训练到上线别自己踩雷
训练-推理一致性
最容易翻车的是归一化:PyTorch 用mean-std,OpenCV 读图是 0-255,一定记得在导出脚本里写死同一套值,最好放json随包发布。版本管理
每产出一次 ONNX 就把 git tag 打上,文件命名带时间戳 + 准确率,如fruits_mv3_96.4_20240518.onnx,回滚不烧脑。冷启动延迟
Jetson 系列第一次加载 ONNX 会编译 CUDA kernel,耗时 3-4 s。答辩前先把ort_sess.run(...)跑一遍 dummy 输入,让 kernel 缓存落盘,真正演示时降到 200 ms 内。批量部署
树莓派集群别直接拉最新模型,用蓝绿发布:新模型先上 20 % 节点,日志无异常再全量,防止一损俱损。日志与监控
记录每次推理耗时、返回置信度、异常分数,用 Prometheus 抓一下,后期做阈值调优有数据支撑,老师问“你怎么保证稳定性”时直接甩图。
写在最后:把“能用”升级成“好用”
整套流程跑通后,我的毕业设计拿了优秀,但回头看仍有不少可玩点:
- 把骨干网络换成 EfficientNetV2,用
torch.fx做量化,再掉 30 % 延迟; - 集成 Grad-CAM,可视化给导师看“模型到底看的是果梗还是颜色”,调参更有说服力;
- 或者干脆做多任务,把“新鲜/腐烂”一起输出,秒变“果蔬品质分级”,论文厚度++。
如果你也在为果蔬分类头秃,不妨先照抄上面的脚本跑通 baseline,再按兴趣点逐步深挖。毕业设计不是终点,把模型真正搬到食堂后厨,让阿姨用手机就能识别库存,才是我们学工程的浪漫。祝你答辩顺利,代码不崩。