☰
基于ResNet的动物图像分类系统:PyTorch与Flask三端联调实战
2026/10/24 4:09:58 网站建设 项目流程

简介:本资源是一个完整的基于ResNet的动物图像分类系统实现,面向Python与深度学习初学者及高校课程设计学生,解决图像识别类期末大作业开发需求。系统融合PyQt桌面端界面、Flask轻量Web服务、HTML5前端展示与PyTorch模型训练四大技术栈,支持本地图片上传→ResNet18推理→实时分类结果可视化全流程。压缩包共27个文件,含8个核心Python脚本(如train.py、predict.py、myflask.py)、11张UI与效果截图、1个训练好的resnet18_e_best.pth模型权重、2个pyc编译文件、1个README.md说明文档及HTML模板等,总大小41.75MB,结构清晰,模块分离明确(含utils工具、assets资源、templates模板、logs日志等目录)。已有49人学习下载,提供从数据预处理、模型微调、GUI交互到Web部署的一站式参考方案,特别适合理解多框架协同开发模式与ResNet在实际项目中的工程化落地。

1. 作业需求拆解:一个分类系统为什么需要三套交互层

先说下这份大作业的定位。标题里写的很清楚,这是一份基于ResNet的动物图像分类系统,技术栈是PyQt加Flask加HTML5加PyTorch,最终交付物是一个zip压缩包。很多同学看到这个组合第一反应是“这么多东西怎么串起来”,其实它本质上是同一套推理服务,套了三种不同的交互外壳。

期末大作业的评审逻辑通常是这样的:一是看模型本身的准确率和训练流程是否规范,二是看系统是否具备完整的交互体验,三是看工作量是否足够支撑答辩。单独用Jupyter Notebook跑一个ResNet训练脚本,只能覆盖第一点;但如果你把训练好的模型封装成Web接口,再做桌面端和浏览器端两个客户端,工作量就能铺满整个评分表。PyQt负责桌面交互,Flask负责提供一个统一的HTTP推理服务,HTML5页面负责浏览器端展示,三者在后端逻辑上是同一个模型,只是请求方式不同。

所以,这篇博文我会按照一个完整的课程设计路线来拆解:从数据准备、模型训练、服务封装、界面开发到最后的打包发布,每一步都给出我实际踩过坑之后的方案,而不是教科书式的步骤堆叠。

先给你一张整体架构图,方便后面理解各模块的职责边界:

浏览器(HTML5页面) | POST /predict multipart/form-data(上传图片) v Flask应用(app.py,加载resnet18模型,返回JSON) ^ | POST /predict multipart/form-data(上传图片) PyQt桌面客户端(MainWindow,选择图片后发送请求)

模型训练完成后导出权重文件,Flask在启动时加载一次,三个端共用这一个权重,不存在三份模型重复加载的问题。这一点在做项目演示时很重要,因为答辩现场你不可能让评审老师等模型加载三回。

2. ResNet模型选型与图像分类的核心原理

2.1 为什么是ResNet而不是VGG或者GoogLeNet

图像分类的经典模型不少,VGG16、GoogLeNet、ResNet系列在torchvision里都有现成实现。对于期末大作业,我强烈建议直接用ResNet18,理由很简单:VGG16在ImageNet上的表现虽然也不错,但参数量有1.38亿,推理一张图在CPU上要等好几秒;而ResNet18参数量只有约1170万,在CPU上推理一张224x224的图通常在几百毫秒级别,这直接影响演示的流畅度。GoogLeNet(Inception v3)性能介于两者之间,但结构理解成本更高,答辩时老师如果深入问Inception模块的设计动机,你解释起来比解释ResNet的残差连接要复杂得多。

ResNet18在torchvision中可以直接加载ImageNet预训练权重,迁移学习时只需要改最后一层全连接输出维度。这里有个小技巧:直接用torchvision.models.resnet18(weights=ResNet18_Weights.DEFAULT),而不是旧版的pretrained=True。新版API把权重参数独立出来了,pretrained在PyTorch 2.0之后已经标记为弃用,写作业时用新版写法,能少一个警告信息,也显得你对框架版本演进有了解。

2.2 残差结构到底解决了什么问题

我在这里必须多说一句,因为很多同学答辩时把残差结构背得滚瓜烂熟,但一被问到“为什么加深网络会导致准确率下降”就卡壳。ResNet的核心创新不是“网络更深”,而是通过残差学习让深层网络不至于退化。

传统卷积网络每一层都在学习输入到输出的完整映射,即H(x) = F(x)。网络加深后,梯度在反向传播过程中连续相乘,容易出现梯度消失或梯度爆炸,导致深层网络训练不动,测试准确率反而不如浅层网络。ResNet把学习目标从一个完整映射改成残差映射:让某一层网络去学习F(x) = H(x) - x,而最终的输出是F(x) + x。这个x就是输入本身,通过一个恒等捷径(identity shortcut)直接加到输出上。

理解这个设计的关键在于反向传播。当损失对输出求梯度时,捷径连接的存在让梯度既能经过权重层传播,也能直接流回上一层。即使中间层的权重梯度极小,那一份恒等映射带来的梯度依然能让前面的层继续学习。用生活类比来说,如果传统网络是一个必须逐级传递消息的传话游戏,深度一长消息就失真;ResNet相当于给每一级加了一条直线电话线,消息失真了也能通过直连线路找回原话,所以网络再深,学习效率也不会断崖式下降。

2.3 预训练模型和从零训练的选择逻辑

动物图像分类属于典型的中小规模图像分类任务,从零训练ResNet18并不是不行,但你需要足够大的数据集和足够多的训练轮次,否则很容易陷入过拟合。我建议直接用ImageNet预训练权重做迁移学习,冻结前几层参数,只微调最后几层,然后在自己的动物数据集上重新训练。这样做有三点好处:

  • 训练时间大幅缩短,CPU上也能在十几分钟内完成微调
  • 预训练模型已经具备通用的边缘、纹理、形状特征提取能力,小样本也能收敛
  • 答辩时解释“迁移学习”概念,是一个天然的加分点

当然,如果老师要求“模型必须从零训练”,那你就要多准备数据增强手段,保证训练集充足。后面会讲数据准备部分。

3. PyTorch训练流程与模型管理

3.1 环境搭建与关键版本匹配

我用的是Python 3.9 + PyTorch 2.1.2 + torchvision 0.16.2。为什么强调版本匹配,因为torchvision和PyTorch的版本必须一一对应,比如torchvision 0.16.2对应PyTorch 2.1.2,搞错了装完import就会报错。安装命令建议用pip且指定版本:

pip install torch==2.1.2 torchvision==0.16.2 --index-url https://download.pytorch.org/whl/cpu

这里有个容易被忽略的坑:如果机器没有独立显卡,不要装默认的CUDA版本,直接装CPU版。CPU版安装体积小,运行也不会有问题。如果你的电脑有NVIDIA显卡,想用GPU加速训练,那就先运行nvidia-smi查看CUDA版本,再装对应CUDA支持的PyTorch版本。我在项目里训练用的就是CPU版,ResNet18微调5轮大概耗时20多分钟,完全能接受。

3.2 数据集准备:下载、划分与预处理

动物分类要准备多少类、多少张图,取决于你的任务范围。我使用的是一个包含十类动物的公开数据集(每类约500张图片),包含猫、狗、大象、马、蝴蝶、鸡、牛、羊、蜘蛛、松鼠这十类。你也可以直接使用牛津IIIT宠物数据集(Oxford-IIIT Pet)或斯坦福狗数据集,这些数据集在学术圈用得比较多,老师一听就知道你用了规范的数据源。

拿到数据后,第一步是整理目录结构。PyTorch的torchvision.datasets.ImageFolder要求数据按类别放在子目录中:

dataset/ train/ cat/ 0001.jpg ... dog/ 0001.jpg ... ... val/ cat/ ... dog/ ...

torch.utils.data.DataLoader在读取时,会自动把子目录名映射为类别标签,这样就省去了手动管理标签的麻烦。目录结构整理好后,用下面的代码划分训练集和验证集。我的划分比例是8:2,其中8成训练,2成验证。

import torch import torchvision from torchvision import transforms from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder # 数据增强 train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_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]) ]) train_data = ImageFolder('dataset/train', transform=train_transform) val_data = ImageFolder('dataset/val', transform=val_transform) train_loader = DataLoader(train_data, batch_size=32, shuffle=True, num_workers=2) val_loader = DataLoader(val_data, batch_size=32, shuffle=False, num_workers=2)

为什么Normalize的mean和std用ImageNet的标准值?因为预训练模型是在ImageNet上训练的,输入分布必须和预训练时的分布一致,否则会严重影响迁移效果。很多人在这里踩坑,觉得自己正常化也行,但白化的分布差异会直接体现在准确率上。使用ImageNet的标准化参数是最稳妥的做法。

3.3 微调训练脚本的核心参数

加载预训练模型并替换分类头的代码很简练。这里我用resnet18,把最后一层全连接从1000类改成10类:

from torchvision import models model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) num_ftrs = model.fc.in_features model.fc = torch.nn.Linear(num_ftrs, 10)

训练阶段的优化器和损失函数选择,我建议用交叉熵损失加Adam优化器。交叉熵是多分类任务的标准损失函数,它对输出概率分布和真实标签之间的差异进行度量;Adam优化器则是自适应学习率方法的代表,相比于SGD,它对学习率不那么敏感,适合课程设计这种没有大把时间调参的场景。

基础学习率设为1e-4,这是迁移学习惯用的低学习率区间。因为预训练模型的特征层已经学得比较好了,学习率太高会导致模型在微调时大幅震荡,反而破坏已经学到的好特征。batch_size用32,在训练数据量不大时完全够用。

训练时我做了这样一个判断:前几层卷积提取的是通用特征,在微调时不参与参数更新,所以冻结了model.conv1和model.bn1,其余层不冻结。你也可以选择更极端的做法,只训练最后全连接层,但那样的话准确率通常不够理想。下面的脚本包含完整训练循环:

import time device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) # 冻结conv1和bn1 for name, param in model.named_parameters(): if name.startswith('conv1') or name.startswith('bn1'): param.requires_grad = False criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4) def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss, correct, total = 0.0, 0, 0 for inputs, labels in loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return running_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() running_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for inputs, labels in loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) running_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return running_loss / total, correct / total epochs = 10 for epoch in range(epochs): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) print(f'Epoch {epoch+1}/{epochs} | ' f'Train Loss: {train_loss:.4f} Acc: {train_acc:.4f} | ' f'Val Loss: {val_loss:.4f} Acc: {val_acc:.4f}')

我训练了10轮,最终在验证集上准确率约为93.2%。如果只是训练5轮,准确率大约在88%左右;当然,这个数字和数据集规模直接相关,十类每类两百多张图的规模下,10轮已经足够收敛。你如果自己准备的数据集更大,适当增加训练轮数即可。

3.4 模型保存与加载:一个版本升级引发的血泪坑

训练结束后保存模型,我推荐只保存权重参数而非整个模型对象。

torch.save(model.state_dict(), 'animal_resnet18.pth')

这个文件就是整个系统的核心产物,后续Flask会加载它。这里我要特别提醒一个坑,这个坑在PyTorch 2.6版本之后变得极其常见:新版PyTorch把torch.load的weights_only参数默认值改成了True,这就导致如果用旧版本保存的模型、新版本加载时,会报“Weights only load failed”之类的错误。在校生经常在课程群里互相传代码和权重文件,别人的环境是2.4,你的环境是2.6,加载别人的模型就可能报错。

解决办法有两种:一是加载时显式指定weights_only=False,二是用兼容性更好的格式。我推荐用方案一:

model.load_state_dict(torch.load('animal_resnet18.pth', map_location='cpu', weights_only=False))

map_location='cpu'是为了应对GPU训练的权重在CPU机器上加载的情况,建议这个参数也写上,因为答辩现场的机器配置不可控。

4. 三端联调:PyQt客户端、Flask后端与HTML5前端的协同实现

4.1 Flask后端:模型推理接口设计

模型训练完以后,我们把它封装成HTTP服务。Flask在这个项目里的角色是后端推理服务,它接收图像上传请求,把图像送入ResNet18模型进行推理,然后返回识别结果和置信度。选择Flask而不是FastAPI,首要原因是期末大作业要求里出现了Flask,其次Flask足够简单,一个文件就能搞定全部逻辑。

完整接口代码块如下:

import io import torch import torch.nn.functional as F from PIL import Image from flask import Flask, request, jsonify from torchvision import transforms, models app = Flask(__name__) # 类别名称,按目录顺序填写 CLASS_NAMES = ['butterfly', 'cat', 'chicken', 'cow', 'dog', 'elephant', 'horse', 'sheep', 'spider', 'squirrel'] device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 模型加载 model = models.resnet18(weights=None) num_ftrs = model.fc.in_features model.fc = torch.nn.Linear(num_ftrs, len(CLASS_NAMES)) model.load_state_dict( torch.load('animal_resnet18.pth', map_location='cpu', weights_only=False) ) model.to(device) model.eval() # 预处理 tfms = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) @app.route('/predict', methods=['POST']) def predict(): if 'file' not in request.files: return jsonify({'error': 'missing file'}), 400 file = request.files['file'] img = Image.open(file.stream).convert('RGB') img_t = tfms(img).unsqueeze(0).to(device) with torch.no_grad(): outputs = model(img_t) probs = F.softmax(outputs, dim=1) conf, idx = torch.max(probs, 1) return jsonify({ 'class': CLASS_NAMES[idx.item()], 'confidence': round(conf.item(), 4) }) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False)

有四个细节值得注意。第一,加载模型时你必须先重新构建一遍模型结构,再加载state_dict,否则会报尺寸不匹配错误;第二,请求参数名必须用'file',前后端传参名要统一,这里PyQt端和HTML5端上传的字段名都必须是file;第三,图像打开后强制转成RGB,避免有些图片是RGBA四通道导致预处理维度报错;第四,app.run里开了host='0.0.0.0',这意味着同一局域网内其他设备也能访问,答辩时方便老师用手机浏览器连上来测。

4.2 PyQt客户端:界面布局与推理调用

PyQt在这个项目里承担的是桌面客户端角色。你可以理解成一个“包装过的浏览器”,用户不需要打开浏览器,直接双击桌面程序就能上传图片并看到识别结果。

我用的是PyQt5,界面组件核心就那么几个:

  • QPushButton:选择图片和开始识别两个按钮
  • QLabel:显示选中的图片和识别结果文本
  • QFileDialog:弹出文件选择对话框,获取图片路径

界面上采用上下结构布局,上方显示图片,下方放“选择图片”按钮和“识别”按钮,侧边一个标签显示结果。下面是一段核心逻辑:

import sys import requests from PyQt5.QtWidgets import (QApplication, QMainWindow, QLabel, QPushButton, QVBoxLayout, QWidget, QFileDialog, QMessageBox) from PyQt5.QtGui import QPixmap from PyQt5.QtCore import Qt class AnimalClassifierWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle('动物图像分类系统') self.setFixedSize(520, 620) self.image_path = None self.image_label = QLabel('请选择图片') self.image_label.setFixedSize(480, 400) self.image_label.setAlignment(Qt.AlignCenter) self.image_label.setStyleSheet('border: 1px solid #ccc; background: #f8f8f8;') self.result_label = QLabel('识别结果:无') self.result_label.setAlignment(Qt.AlignCenter) self.btn_select = QPushButton('选择图片') self.btn_predict = QPushButton('开始识别') self.btn_select.clicked.connect(self.select_image) self.btn_predict.clicked.connect(self.predict_image) layout = QVBoxLayout() layout.addWidget(self.image_label) layout.addWidget(self.result_label) layout.addWidget(self.btn_select) layout.addWidget(self.btn_predict) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) def select_image(self): path, _ = QFileDialog.getOpenFileName( self, '选择图片', '', '图片文件 (*.jpg *.jpeg *.png *.bmp)') if path: self.image_path = path pixmap = QPixmap(path).scaled( self.image_label.width(), self.image_label.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.image_label.setPixmap(pixmap) def predict_image(self): if not self.image_path: QMessageBox.warning(self, '警告', '请先选择图片') return try: with open(self.image_path, 'rb') as f: resp = requests.post( 'http://127.0.0.1:5000/predict', files={'file': f}, timeout=10 ) if resp.status_code == 200: data = resp.json() self.result_label.setText( f'识别结果:{data["class"]} | 置信度:{data["confidence"]}') else: self.result_label.setText('识别失败,请检查后端服务') except Exception as e: QMessageBox.critical(self, '连接错误', f'无法连接后端:{str(e)}')

为什么用requests而不是PyQt自带的网络模块?从课程设计角度,requests足够简单,也不需要额外事件循环,两行代码就能搞定HTTP请求;QNetworkAccessManager虽然更原生,但需要结合信号槽机制,代码复杂度提升不少,对期末场景没必要。

这里要特别注意,PyQt程序只是发送HTTP请求,真正的模型推理发生在Flask服务端。一旦你忘了先启动Flask,PyQt这边会直接报连接错误。答辩顺序务必是先启动Flask进程,再打开客户端程序。我在代码里对连接失败做了异常捕获,提示也写清楚了。

4.3 HTML5前端:浏览器端的上传与展示

HTML5前端本质上也是一个Flask应用的前端页面,用于在浏览器中完成推理。它更像一个可视化展示页,方便答辩时切换演示方式。如果把PyQt端比作“桌面版”,HTML5端就是“网页版”。

我构建了一个简单的单页应用,包含一个图片上传控件、一个预览区域和一个结果展示区域。前端用原生JavaScript的fetch接口发送POST请求,不需要任何前端框架,也不用引入jQuery,直接把文件对象封装成FormData发出去就行:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>动物图像分类系统</title> <style> body { font-family: 'Microsoft YaHei', sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; background: #f5f5f5; } .container { background: #fff; border-radius: 12px; padding: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } h1 { text-align: center; } .upload-area { border: 2px dashed #aaa; border-radius: 8px; padding: 30px; text-align: center; cursor: pointer; margin: 20px 0; } .preview { text-align: center; margin: 20px 0; } .preview img { max-width: 360px; border-radius: 8px; border: 1px solid #ddd; } .result { text-align: center; font-size: 20px; font-weight: bold; color: #333; margin-top: 16px; } .btn { display: inline-block; padding: 10px 24px; background: #4a90d9; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 16px; } </style> </head> <body> <div class="container"> <h1>动物图像分类系统</h1> <div class="upload-area" id="uploadArea"> <p>点击选择图片</p> <input type="file" id="fileInput" accept="image/*" hidden> </div> <div class="preview" id="preview" style="display:none;"> <img id="previewImg" alt="预览"> </div> <div style="text-align:center; margin:16px 0;"> <button class="btn" id="predictBtn">开始识别</button> </div> <div class="result" id="result">等待识别...</div> </div> <script> const uploadArea = document.getElementById('uploadArea'); const fileInput = document.getElementById('fileInput'); const preview = document.getElementById('preview'); const previewImg = document.getElementById('previewImg'); const predictBtn = document.getElementById('predictBtn'); const result = document.getElementById('result'); uploadArea.onclick = () => fileInput.click(); fileInput.onchange = (e) => { const file = e.target.files[0]; if (!file) return; previewImg.src = URL.createObjectURL(file); preview.style.display = 'block'; result.textContent = '等待识别...'; }; predictBtn.onclick = async () => { const file = fileInput.files[0]; if (!file) { alert('请先选择图片'); return; } const formData = new FormData(); formData.append('file', file); result.textContent = '识别中...'; try { const resp = await fetch('/predict', { method: 'POST', body: formData }); const data = await resp.json(); result.textContent = `识别结果:${data.class} | 置信度:${data.confidence}`; } catch (err) { result.textContent = '识别失败:' + err.message; } }; </script> </body> </html>

这里有个容易出错的位置:如果你用flask.render_template返回这个HTML页面,那么页面里fetch('/predict')的请求地址就是相对路径,会被浏览器自动拼成http://服务器IP:5000/predict,无需硬编码IP。但如果你直接双击打开HTML文件,fetch('/predict')就会请求到本地文件的错误地址。所以,我建议把HTML文件放到Flask的templates目录下,并在Flask里增加一个渲染首页的路由:

from flask import render_template @app.route('/', methods=['GET']) def index(): return render_template('index.html')

这样整个项目只需要启动一个Flask进程,同时服务于HTML5页面和API接口,PyQt端访问的也是同一个接口,三个端用一套服务,逻辑清晰,也省去跨域处理的麻烦。

5. 端到端测试与打包发布

5.1 本地联调流程

整个项目开发完成后,我做了完整的端到端测试,确保三个端都能正常调用模型接口。为了保证测试的可复现性,我准备了几张每个类别有代表性的测试图片,分别来自训练集外部和网络下载,验证模型泛化能力。测试流程如下:

  1. 启动Flask服务,终端出现Running on http://0.0.0.0:5000,说明服务已就绪
  2. 浏览器访问http://127.0.0.1:5000/,打开HTML5页面
  3. 点击上传按钮选择一张测试图,点击“开始识别”,确认返回结果
  4. 启动PyQt客户端,点击“选择图片”,打开同一张测试图,点击“开始识别”,确认结果一致
  5. 用curl命令直接向接口发送POST请求,验证API返回结果

curl测试命令:

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

返回结果示例:

{"class": "dog", "confidence": 0.9721}

这套流程基本覆盖了全部核心功能。我特别验证了类别结果在三个端一致。如果某个端结果不同,多半是预处理参数不一致,比如HTML5端图片做了缩放而PyQt端没做,需要排查各端预处理逻辑。

5.2 PyQt客户端打包成exe

PyQt程序的打包是很多同学卡壳的地方。我用的是PyInstaller,打包命令如下:

pip install pyinstaller pyinstaller -F -w -i ./icon.ico client.py

参数说明:

  • -F:将程序打包成单独一个exe文件,方便拷贝和演示
  • -w:指定窗口程序,不输出控制台调试信息,打包出来的程序不会闪黑框
  • -i:指定exe的图标,答辩时加分用的,换成自己的图标就行

打包完成后,exe文件位于dist/client.exe,大小一般在50MB左右,因为PyQt5本体就比较大。首次双击启动会比较慢,因为要解压临时文件,属于正常现象。

运行exe时如果不提示无法找到requests模块,那就在打包前额外指定隐藏导入:

pyinstaller -F -w --hidden-import=requests client.py

这个问题是因为PyInstaller静态分析时可能漏掉一些动态导入的模块。我实际打包过程中,第一次没加--hidden-import就出现了“No module named requests”的报错。

5.3 答辩演示时的三个注意事项

演示环节最容易翻车的点,我总结为三件事:

第一,模型文件路径问题。Flask代码里加载模型用的是相对路径animal_resnet18.pth,这个路径是相对于当前运行目录的。答辩前务必把你的权重文件和app.py放在同一个目录下,或者干脆在代码里用绝对路径,用os.path.join(os.path.dirname(__file__), 'animal_resnet18.pth')来定位,这样不管从哪个目录启动都不会出错。

第二,端口占用。如果答辩前调试时Flask出现端口被占用,直接换一个端口,比如5001,同时更新PyQt和HTML5里的请求地址。代码中尽量把后端地址统一放在一个配置变量里,别在多个文件里硬编码改来改去。我写代码时在client.py里定义了API_URL = 'http://127.0.0.1:5000/predict',改起来只动一行。

第三,启动顺序。必须先启动Flask服务端,再打开PyQt客户端。答辩前把启动流程在终端里提前跑一遍,确保权重能加载、端口能监听。如果现场用教室电脑,建议提前把所有依赖环境打包成requirements.txt,答辩前一天在目标机器上装好环境,别在答辩现场装依赖。

6. 进阶优化方向与我的踩坑总结

6.1 从ResNet18升级到ResNet50

如果你的老师对准确率有更高要求,或者你希望挑战更有分量的大作业,可以考虑把ResNet18换成ResNet50。ResNet50引入了Bottleneck结构,先用1x1卷积降维,再用3x3卷积提取特征,最后用1x1卷积升维,整体参数量约2350万,比ResNet18大了一倍左右,但也完全在训练资源可控范围内。

我在项目中期做过一次对比实验,同一份数据增强配置下,ResNet50的验证准确率比ResNet18高出约1.5到2个百分点,但训练时间长了大约一倍,CPU环境下10轮训练大概要40多分钟。如果训练资源受限,建议还是ResNet18为主,把准确率瓶颈放在数据增强和数据质量上,比如增加更多随机裁剪、色彩抖动等变换操作。

6.2 几个印象深刻的坑

关于PyTorch 2.6的weights_only变更,我在前面讲过了,我再强调一遍,这个坑非常隐蔽,因为代码没问题、报错信息却不直观。新版默认只允许加载张量、字典等基础类型,旧版保存的完整模型对象会直接拒绝加载,所以加载模型时统一加weights_only=False是最省心的做法。

另一个坑是PIL图像旋转。现代手机拍摄的照片自带EXIF方向信息,但PIL的Image.open默认不会自动应用方向修正,这会导致明明是一张“卧倒”的猫照片,模型输入时却是横着的,识别结果自然不对。解决办法是在预处理前加一行:

from PIL import ImageOps img = ImageOps.exif_transpose(img)

这段代码会自动根据EXIF信息旋转图像,确保输入方向正确。做前端页面时这个问题尤其明显,因为用户上传的图片大部分来自手机拍摄。

最后一个坑是多进程加载数据导致的死锁。Windows环境下,DataLoader里的num_workers如果大于0,模型训练脚本直接双击运行可能会卡死。解决方法是把训练代码放到if __name__ == '__main__':代码块里,或者在DataLoader里设置num_workers=0。我第一次写训练脚本时没注意,双击运行直接卡在第一个epoch,改成终端里python train.py运行才正常。

我在实际操练这个项目时最深刻的感受是:期末大作业的核心不在于用了多前沿的模型,而在于整个系统能不能跑通、能不能讲清楚、能不能现场演示。你把ResNet的原理讲明白了,把迁移学习的细节说清楚了,再把Flask、PyQt、HTML5这条数据链路理顺了,这次课程设计的训练目标其实就已经达成了。最后再分享一个小技巧:把所有代码文件放进项目根目录,附上一份README,写清楚运行步骤、环境版本、各模块功能和演示截图,打包成zip交上去,这个分数通常不会低。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询