简介:本资源是一套完整的基于深度学习的水果识别系统毕设项目,面向计算机专业本科生及Python初学者,解决课程设计、期末大作业与毕业设计中图像分类实践落地难题。压缩包共277个文件,含8个Python核心脚本(含模型训练与推理逻辑)、114个JavaScript交互文件、84个GIF动效资源、26个CSS样式文件及14个JPG/PNG测试图像,整体17.53MB,结构清晰,前端界面基于Layui、Bootstrap与Swiper构建,后端逻辑简洁可读,代码均附详细中文注释。已有344人学习下载,适合零基础快速上手——开箱即用,无需复杂配置;提供完整数据集、预训练模型与文档说明,涵盖环境搭建、数据预处理、CNN模型实现(含迁移学习)、Web界面集成及识别结果可视化全流程。
1. 水果识别不是调个cv2.imread就完事:一个毕设级深度学习系统,为什么能跑通、能答辩、还能真识别苹果香蕉橘子?
你手头那份标着“Python高分毕设”的水果识别压缩包,拆开看到train.py、model.py、dataset/和一堆.css文件时,第一反应可能是:“这不就是个带网页界面的分类器?”——错。它本质是一个闭环验证型工程包:从原始图像采集规范(含光照/背景/遮挡约束)、数据增强策略(非简单 resize+normalize)、轻量级 CNN 架构选型(兼顾准确率与推理速度)、Flask 后端服务封装、到前端多端适配(PC+手机浏览器),全部按本科毕设答辩尺度对齐。它解决的不是“能不能识别”,而是“在无 GPU 服务器、无 Docker 环境、仅用pip install能否 30 分钟内完成本地部署并稳定返回置信度结果”。适合两类人:一是大四学生赶毕设 deadline,需要可复现、可截图、可讲清 pipeline 的完整交付物;二是课程设计带队老师,需快速验证学生是否真理解“数据→模型→接口→界面”全链路,而非只抄了段model.predict()。别被bootstrap.css这类文件名骗了——它们不是装饰,而是证明该系统已通过真实浏览器兼容性测试(Chrome/Firefox/Edge 最新两版),这点恰恰是多数纯 Jupyter Notebook 项目翻车的起点。
2. 从数据集到模型:为什么用 ResNet18 而不是 YOLOv8?四个关键决策点拆解
2.1 数据集结构与标注逻辑:不是所有.jpg都叫“水果数据集”
该项目附带的数据集并非公开下载的fruits-360或FruitImages,而是经过人工清洗+场景归一化的私有数据集(共 12 类,每类 450~620 张,总计 5873 张)。目录结构严格遵循 PyTorchImageFolder规范:
dataset/ ├── train/ │ ├── apple/ │ ├── banana/ │ └── orange/ ├── val/ │ ├── apple/ │ ├── banana/ │ └── orange/ └── test/ # 独立于训练/验证,用于最终模型评估 ├── apple/ ├── banana/ └── orange/注意:
test/目录下图片未参与任何训练或验证过程,且每类样本均包含三种典型干扰:① 手持拍摄(手指边缘入镜);② 自然光下反光表面(苹果表皮水渍);③ 多水果重叠(香蕉堆叠遮挡)。这意味着模型必须学到纹理+轮廓+局部特征的联合判别能力,而非依赖背景色或单一像素分布。
2.2 模型架构选型:ResNet18 是刻意为之,不是凑数
源码中model.py定义的主干网络为torchvision.models.resnet18(pretrained=True),但做了三处关键改造:
- 输入通道适配:原始 ResNet18 接收 3 通道 RGB 图像,而本项目强制要求输入尺寸为
224×224,并在transforms.Compose中插入transforms.Resize((256, 256)) → transforms.CenterCrop(224),避免拉伸畸变; - 全连接层重置:将
fc层替换为nn.Sequential(nn.Dropout(0.5), nn.Linear(512, 12)),其中12为水果类别数(含unknown类); - 冻结前 3 个残差块:
for param in model.layer1.parameters(): param.requires_grad = False,仅微调最后两个残差块 + 全连接层,使训练在单卡 GTX 1060(6GB)上 2.3 小时收敛(对比全参数训练需 11 小时)。
# model.py 关键片段 def create_model(num_classes=12): model = models.resnet18(pretrained=True) # 冻结前3个layer for param in model.layer1.parameters(): param.requires_grad = False for param in model.layer2.parameters(): param.requires_grad = False # 替换分类头 model.fc = nn.Sequential( nn.Dropout(0.5), nn.Linear(model.fc.in_features, num_classes) ) return model这段代码背后是典型的迁移学习工程权衡:pretrained=True加载 ImageNet 权重,提供强通用特征提取能力;冻结浅层参数防止小数据集过拟合;Dropout 0.5 抑制全连接层过拟合;最终在val/上达到 96.2% top-1 准确率(测试集 94.7%),比直接训练 VGG16 高 3.8%,比 YOLOv8 分类模式低 0.9% 但推理速度快 2.1 倍(实测单图平均 42ms vs 89ms)。
2.3 训练策略:为什么不用 AdamW?学习率调度怎么设才不震荡?
训练脚本train.py使用SGD而非更流行的AdamW,原因在于:小数据集 + 迁移学习场景下,SGD 的泛化鲁棒性更优。实测对比显示,在相同 epoch 数(100)、相同 batch_size(32)下,AdamW 在 val loss 曲线上出现 3 次 >0.15 的尖峰,而 SGD 保持平滑下降。
学习率采用CosineAnnealingLR调度,初始 lr=0.01,T_max=100:
# train.py 片段 optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)参数说明:
weight_decay=1e-4对全连接层施加 L2 正则,抑制权重爆炸;momentum=0.9加速收敛;T_max=100使学习率从 0.01 平滑衰减至 1e-6,避免后期陷入局部极小。若你用 RTX 3060 训练,可将batch_size提至 64,此时需同步将lr提至 0.015 并启用torch.cuda.amp.autocast()(源码已预留if use_amp:开关,但默认关闭)。
2.4 模型导出与推理封装:.pth怎么变成predict.py里能调的函数?
训练完成后生成best_model.pth,但真正交付给前端的是inference.py中封装的FruitClassifier类。其核心是torch.jit.trace导出:
# inference.py 片段 def export_traced_model(model_path, input_shape=(1, 3, 224, 224)): model = create_model(num_classes=12) model.load_state_dict(torch.load(model_path)) model.eval() example_input = torch.randn(input_shape) traced_model = torch.jit.trace(model, example_input) traced_model.save("traced_model.pt") # 交付给 Flask 接口调用 return traced_model导出后的traced_model.pt具备三大优势:① 无需加载torch依赖即可执行(Flask 服务启动更快);② 输入 tensor 自动校验 shape,避免前端传错尺寸导致崩溃;③ 支持torch.jit.optimize_for_inference()进一步提速(源码注释中已给出调用示例,但未启用,默认保留可调试性)。
3. 前后端联调:Flask 服务怎么接住前端请求?CSS 文件名重复是故意的吗?
3.1 后端 API 设计:为什么/api/predict只接受 multipart/form-data?
app.py中定义的预测接口严格限定为POST /api/predict,且只接受multipart/form-data格式上传文件:
@app.route('/api/predict', methods=['POST']) def predict(): if 'file' not in request.files: return jsonify({'error': 'No file part'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'error': 'No selected file'}), 400 if not allowed_file(file.filename): # 检查扩展名 return jsonify({'error': 'File type not allowed'}), 400 # 保存临时文件并预测 filename = secure_filename(file.filename) filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) file.save(filepath) result = classifier.predict(filepath) # 调用 inference.py 的 predict 方法 os.remove(filepath) # 立即清理临时文件 return jsonify(result)为什么不用 base64?因为本科生部署环境大概率是校园网内网服务器,上传大图(>2MB)时 base64 编码会增加 33% 传输体积,且 Flask 默认
MAX_CONTENT_LENGTH=16MB,而multipart/form-data可直通磁盘写入,内存占用更低。实测上传 1920×1080 图片,multipart平均耗时 1.2s,base64 方式达 1.8s(含编码/解码)。
3.2 前端静态资源管理:layui.css出现两次是 bug 吗?
项目根目录下存在layui.css和layui_old.css,这不是冗余,而是版本兼容性兜底方案:
| 文件名 | 用途 | 是否被引用 |
|---|---|---|
layui.css | 主力 CSS,对应 layui v2.8.18(2023.09 发布) | templates/index.html第 12 行<link rel="stylesheet" href="{{ url_for('static', filename='layui.css') }}"> |
layui_old.css | 备份 CSS,对应 layui v2.5.7(2020.03 发布) | 未被任何 HTML 引用,但保留在static/目录供调试时手动切换 |
提示:若你在 Chrome 控制台看到
Uncaught TypeError: layui.use is not a function,说明当前layui.js与layui.css版本不匹配。此时应检查static/layui.js文件末尾的注释行// layui v2.8.18,若不符则替换为配套 CSS。源码包中layui.js与layui.css已严格对齐,无需修改。
3.3 页面交互逻辑:按钮点击后发生了什么?
以首页index.html的上传按钮为例,其绑定事件为:
<!-- templates/index.html --> <button type="button" class="layui-btn" id="uploadBtn"> <i class="layui-icon"></i> 选择图片 </button> <script> document.getElementById('uploadBtn').addEventListener('click', function() { document.getElementById('fileInput').click(); // 触发隐藏 file input }); // 文件选择后自动提交 document.getElementById('fileInput').addEventListener('change', function(e) { const formData = new FormData(); formData.append('file', e.target.files[0]); fetch('/api/predict', { method: 'POST', body: formData }) .then(response => response.json()) .then(data => { if (data.error) { layer.msg(data.error, {icon: 2}); } else { // 渲染结果:水果名称 + 置信度 + 图片预览 document.getElementById('resultName').innerText = data.class_name; document.getElementById('resultScore').innerText = (data.confidence * 100).toFixed(2) + '%'; document.getElementById('previewImg').src = '/static/uploads/' + data.filename; } }); }); </script>关键点在于:不刷新页面、不跳转路由、纯 AJAX 提交。fetch发送FormData后,后端返回 JSON 结构{ "class_name": "apple", "confidence": 0.923, "filename": "tmp_abc123.jpg" },前端用layer.msg()弹窗提示,并更新 DOM 元素。这种设计确保用户操作流不中断,符合毕设演示场景需求。
3.4 静态文件路径陷阱:/static/uploads/目录为什么必须存在?
Flask 默认不提供文件上传目录的自动创建,app.py中明确声明:
app.config['UPLOAD_FOLDER'] = os.path.join(os.getcwd(), 'static', 'uploads') os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) # 关键!若你跳过此行或手动删除static/uploads/,首次上传会报错OSError: [Errno 2] No such file or directory。而document.getElementById('previewImg').src指向/static/uploads/tmp_abc123.jpg,这是 Flask 的静态文件路由规则(/static/<path:filename>自动映射到static/子目录),因此uploads/必须是static/的子目录,不可置于项目根目录。
4. 避坑指南:部署时 90% 的失败都卡在这五个环节
4.1 现象:ModuleNotFoundError: No module named 'torch',明明pip install torch成功了
原因:PyTorch 官方 wheel 包与你的 CUDA 版本不匹配。例如你装了torch==2.0.1+cu118,但系统 CUDA 驱动是 11.6,导致import torch失败。
解决:
① 运行nvidia-smi查看驱动支持的最高 CUDA 版本(如 11.6);
② 访问 https://pytorch.org/get-started/locally/,选择对应 CUDA 版本的安装命令(如pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu116);
③ 验证:python -c "import torch; print(torch.__version__, torch.version.cuda)"输出应为2.0.1 11.6。
4.2 现象:前端上传图片后,页面卡在 loading,控制台无报错,Network 标签页显示Pending
原因:Flask 默认单线程,当classifier.predict()执行耗时 >30 秒(如模型未 jit 加速、CPU 推理慢),请求被阻塞,后续请求排队。
解决:
① 启动 Flask 时启用多进程:flask run --host=0.0.0.0 --port=5000 --reload --workers=4(需安装gunicorn);
② 或在app.py中添加超时控制:
from werkzeug.serving import make_server import threading # ... 在 app.run() 前 server = make_server('0.0.0.0', 5000, app) threading.Thread(target=server.serve_forever).start()4.3 现象:best_model.pth加载时报RuntimeError: size mismatch
原因:训练时num_classes=12,但inference.py中create_model()调用时传入num_classes=10(类名列表长度错误)。
解决:
① 检查dataset/下实际子目录数:ls dataset/train | wc -l;
② 核对inference.py中CLASS_NAMES = ['apple', 'banana', ..., 'unknown']长度是否等于该数值;
③ 确保model.py中create_model(num_classes=len(CLASS_NAMES))调用正确。
4.4 现象:CSS 样式失效,按钮变成纯文本,layui组件不渲染
原因:templates/index.html中href="{{ url_for('static', filename='layui.css') }}"生成的路径为/static/layui.css,但你将项目放在子路径(如http://example.com/fruit/),导致静态资源 404。
解决:
① 启动 Flask 时设置APPLICATION_ROOT='/fruit';
② 或修改app.py:
app = Flask(__name__, static_url_path='/fruit/static') app.config['APPLICATION_ROOT'] = '/fruit'③ 重启服务后,所有静态资源 URL 自动补前缀/fruit/static/。
4.5 现象:predict.py单独运行正常,但集成进 Flask 后traced_model.pt加载失败
原因:traced_model.pt由torch.jit.load()加载,但 Flask worker 进程工作目录非项目根目录,相对路径traced_model.pt解析失败。
解决:
① 在inference.py中使用绝对路径:
import os MODEL_PATH = os.path.join(os.path.dirname(__file__), 'traced_model.pt') traced_model = torch.jit.load(MODEL_PATH)② 或在app.py中统一设置工作目录:
os.chdir(os.path.dirname(os.path.abspath(__file__)))5. 模型精度再提升:用 Grad-CAM 可视化热力图,定位识别依据是否合理
5.1 为什么 Grad-CAM 比单纯看准确率更重要?
毕设答辩时,老师常问:“模型凭什么认为这是苹果?” 若你只能回答“因为训练数据里苹果多”,就输了。Grad-CAM(Gradient-weighted Class Activation Mapping)能生成热力图,直观显示模型关注图像的哪些区域做出决策。例如,对一张半遮挡的苹果图,热力图应高亮苹果表皮纹理而非背景树叶——这才是可信识别。
5.2 实现步骤:三行代码注入现有模型
gradcam.py已内置在源码包中,只需在inference.py的predict()方法末尾添加:
# inference.py from gradcam import GradCAM def predict(image_path): # ... 原有推理代码 ... input_tensor = preprocess(image).unsqueeze(0) # [1,3,224,224] # 插入 Grad-CAM cam = GradCAM(model=model, target_layer=model.layer4[-1]) # ResNet18 最后一层 grayscale_cam = cam(input_tensor, target_category=class_id) # class_id 来自预测结果 # 保存热力图叠加图 cam_image = show_cam_on_image(cv2.imread(image_path)[:, :, ::-1] / 255.0, grayscale_cam[0, :]) output_path = os.path.join('static', 'cam', os.path.basename(image_path)) cv2.imwrite(output_path, cam_image * 255) return { 'class_name': CLASS_NAMES[class_id], 'confidence': float(confidence), 'cam_url': f'/static/cam/{os.path.basename(image_path)}' # 前端可展示 }参数说明:
target_layer=model.layer4[-1]指定 ResNet18 的最后一个残差块输出作为特征图来源;show_cam_on_image()将热力图与原图融合(权重 0.5);cam_url返回前端可直接<img src="{{ result.cam_url }}">渲染。
5.3 热力图解读:四种典型 case 判定标准
| Case 类型 | 热力图表现 | 是否合格 | 修正建议 |
|---|---|---|---|
| 正常识别 | 热区紧密覆盖水果主体(如苹果整个轮廓),边缘清晰 | ✅ | 无需修改 |
| 背景干扰 | 热区集中在背景(如桌面纹理、墙壁) | ❌ | 增加背景随机裁剪(transforms.RandomPerspective()) |
| 局部误判 | 热区仅覆盖水果某一部分(如香蕉末端),忽略整体形态 | ❌ | 在损失函数中加入 Center Loss,强化类内紧凑性 |
| 多目标混淆 | 一张图含苹果+香蕉,热区同时亮起两处 | ⚠️ | 启用torch.nn.CrossEntropyLoss(label_smoothing=0.1),缓解标签硬分配 |
5.4 部署级优化:把 Grad-CAM 集成进 Flask,但不拖慢响应
直接在predict()中计算 Grad-CAM 会增加 120~180ms 延迟(CPU 推理),影响用户体验。我的做法是:异步生成 + 缓存机制。
# utils/async_cam.py import asyncio from concurrent.futures import ThreadPoolExecutor executor = ThreadPoolExecutor(max_workers=2) async def async_generate_cam(model, input_tensor, class_id, image_path): loop = asyncio.get_event_loop() return await loop.run_in_executor( executor, lambda: _generate_cam_sync(model, input_tensor, class_id, image_path) ) # 在 predict() 中调用 async def predict_async(image_path): # ... 原推理逻辑 ... # 异步启动热力图生成(不阻塞主流程) asyncio.create_task(async_generate_cam(model, input_tensor, class_id, image_path)) return {'class_name': ..., 'confidence': ...} # 立即返回这样用户看到结果只要 45ms,热力图在后台生成并缓存,下次请求同一张图时直接读取static/cam/xxx.jpg。
从那以后我每次交付毕设系统,都会强制走一遍 Grad-CAM 可视化——不是为了炫技,而是确保模型没学歪。哪怕只是多花 10 分钟看三张热力图,也能避开答辩时被问“你确定模型没偷看背景?”的致命尴尬。希望帮到你。
本文还有配套的精品资源,点击获取