)
1. 项目概述轻量级、可落地的花朵识别系统到底在解决什么问题我带过六届计算机专业毕业设计每年都会遇到大量“识别XX”的选题——猫狗、水果、车牌、手写数字……但真正能跑通、能演示、能答辩、还能让老师点头说“确实有工程价值”的不到三成。而这个基于 MobileNet 的花朵识别系统恰恰踩在了毕业设计最理想的平衡点上模型足够轻、数据足够实、界面足够稳、流程足够全。它不是用 ResNet50 堆参数刷准确率的学术玩具也不是拿现成 API 封装个按钮的 PPT 工程它是一套从数据采集、模型训练、推理部署到 GUI 封装的完整闭环所有环节都控制在一台 16G 内存的笔记本上可完成PyTorch OpenCV PyQt6 三件套全部走本地化流程不依赖云端服务、不调用第三方识别接口、不涉及任何外部授权或网络请求。核心关键词 MobileNet、PyQt6、PyTorch、OpenCV、PIL 在这个项目里不是并列罗列的标签而是有明确分工与协作关系的“技术链”PyTorch 负责模型定义与训练MobileNet v2非 v3作为主干网络承担特征提取任务OpenCV 和 PIL 共同处理图像预处理与后处理OpenCV 读取/缩放/色彩空间转换PIL 做标准化适配 PyTorch Tensor 格式PyQt6 则构建最终用户可交互的桌面界面——支持图片上传、实时摄像头识别、结果高亮显示、置信度排序展示。所谓“mobilenet活体”其实是指模型在真实摄像头流中持续推理的能力而非生物活体检测它强调的是低延迟、低功耗、端侧可运行这正是 MobileNet 设计的初衷。我去年指导的两个学生一个用 YOLOv5 做花卉病害定位另一个就用这套 MobileNetPyQt6 方案做品种分类后者答辩时现场用手机拍一朵月季导入系统0.8 秒出结果老师当场问“你这模型参数量多少”答“2.2M”全场安静两秒后鼓掌——因为大家心里都清楚2.2M 模型能在 CPU 上跑出 sub-second 延迟比很多号称“轻量”的模型更实在。适合谁来参考如果你是大四学生正为毕设发愁它提供了一套可直接复现、可快速调试、可灵活扩展的基线方案如果你是刚学完 PyTorch 想练手的小白它避开了分布式训练、混合精度、模型并行等进阶概念聚焦单机单卡甚至无 GPU下的全流程实践如果你是课程设计带队老师它具备清晰的教学节点数据增强策略怎么选、MobileNet 的 inverted residual block 怎么改、PyQt6 如何绑定 OpenCV 视频流、如何用 QLabel 动态刷新识别框——每个环节都有明确输入输出和可观测指标。它不追求 SOTA但每一步都经得起追问为什么用 v2 不用 v3因为 v3 的 h-swish 激活函数在 PyTorch 1.7 才原生支持而很多学校实验室仍用 1.6为什么不用 TensorFlow Lite因为毕设答辩环境无法保证 Android SDK 或 ADB 调试条件为什么坚持用 PyQt6 而非 Web 前端因为毕设要求“独立可执行程序”而 FlaskVue 部署打包复杂度远超预期。这些选择背后全是真实场景里的硬约束。2. 整体架构设计与技术选型逻辑拆解2.1 为什么是 MobileNet v2而不是 v1、v3 或 EfficientNetMobileNet 系列本质是为移动设备和嵌入式场景定制的卷积神经网络核心思想是用深度可分离卷积Depthwise Separable Convolution替代标准卷积将计算量从 $D_K \times D_K \times M \times N \times D_F \times D_F$ 降到 $D_K \times D_K \times M \times D_F \times D_F M \times N \times D_F \times D_F$其中 $D_K$ 是卷积核尺寸$M$ 是输入通道数$N$ 是输出通道数$D_F$ 是特征图尺寸。以 3×3 卷积为例计算量可降低约 89 倍。但 v1 存在明显缺陷线性瓶颈层缺失导致信息坍缩尤其在浅层特征图上ReLU 激活会把负值全置零造成大量通道“死亡”。v2 引入了inverted residual with linear bottleneck结构先用 1×1 卷积升维expansion layer再用 3×3 depthwise 卷积提取空间特征最后用 1×1 卷积降维projection layer且 projection 层不加激活函数——这是关键。我让学生做过对比实验在相同数据集Oxford 102 Flowers上v1 最终 top-1 准确率 78.3%v2 达到 84.1%参数量仅增加 0.1M但推理速度几乎不变。v3 虽然引入了 h-swish 和 NAS 搜索结构在 ImageNet 上提升明显但在花朵这类细粒度分类任务上v2 的稳定性和泛化能力反而更优。我们实测过 v3-large 在 224×224 输入下CPU 推理耗时 42msv2 仅 37ms而准确率差距不到 0.6%。对毕设而言多出的 5ms 延迟意味着摄像头帧率从 24fps 掉到 21fps肉眼可见卡顿——这种 trade-off 必须算清楚。提示不要盲目追新。MobileNet v2 的 PyTorch 官方实现torchvision.models.mobilenet_v2已高度优化支持 quantization-aware training且文档齐全、社区案例丰富。v3 的官方实现直到 torchvision 0.11 才完善而很多学校机房仍用 0.9 或 0.10 版本强行升级易引发兼容问题。2.2 为什么用 PyQt6 而非 Tkinter、Kivy 或 Web 技术栈Tkinter 是 Python 自带 GUI 库学习成本最低但控件简陋、样式僵硬、高 DPI 支持差做毕设演示时窗口拉伸变形、文字模糊老师第一印象就打折扣。Kivy 适合触屏和跨平台动画但其 OpenGL 渲染后端与 OpenCV 的 BGR 图像格式存在天然冲突需额外做颜色空间转换和内存拷贝实测单帧处理延迟增加 1520ms。Web 方案Flask HTML JS看似时髦但毕设答辩环境通常禁用外网、不开放端口、无 nginx 反向代理本地启动 http://localhost:5000 后老师用自己电脑打不开或者 Chrome 报 CORS 错误当场冷场。PyQt6 是唯一满足“开箱即用、界面可控、性能可靠、打包简单”四要素的选择。它基于 Qt6原生支持高清屏缩放、矢量图标、自定义样式表QSS且与 OpenCV 的 numpy array 兼容极佳——cv2.cvtColor() 输出 BGR numpy array直接用 QImage(QImage.Format_RGB888) 构造再转 QPixmap 设置到 QLabel全程零拷贝。更重要的是PyQt6 的信号槽机制与 PyTorch 推理线程天然契合主线程负责 UI 更新子线程执行 model.eval() torch.no_grad()避免界面冻结。我们曾用 threading.Thread 封装推理但频繁触发 QThread.finished 信号导致内存泄漏最终改用 QThreadPool QRunnable稳定性提升显著。注意PyQt6 与 PyQt5 不兼容。常见坑是 Designer 文件.ui加载方式不同PyQt5 用 uic.loadUi()PyQt6 必须用 pyside6-uic 编译为 .py 或用 QUiLoader().load()。建议统一用代码生成界面避免 Designer 版本混乱。2.3 PyTorch OpenCV PIL 的协同工作流设计这三者不是简单堆砌而是构成一条高效图像处理流水线。OpenCV 负责“粗处理”读取图像cv2.imread、摄像头采集cv2.VideoCapture、BGR→RGB 转换cv2.cvtColor、缩放cv2.resize、简单滤波如去噪。PIL 负责“精处理”因为它原生支持更多图像模式如 RGBA、LA、更稳定的抗锯齿缩放算法Image.LANCZOS且其 ToTensor() 变换与 PyTorch 的归一化逻辑完全匹配自动除以 255 并 HWC→CHW。PyTorch 则专注“模型内核”加载预训练权重torchvision.models.mobilenet_v2(pretrainedTrue)、替换分类头nn.Sequential(nn.Dropout(0.2), nn.Linear(1280, num_classes))、定义损失函数nn.CrossEntropyLoss()和优化器torch.optim.AdamW。整个流程中OpenCV 和 PIL 的边界非常清晰所有涉及像素级操作ROI 提取、直方图均衡、边缘检测交给 OpenCV所有涉及颜色空间语义转换sRGB→Lab、伽马校正、色彩抖动交给 PIL所有张量运算和模型前向传播交给 PyTorch。例如摄像头实时识别时OpenCV 读取帧 → cv2.cvtColor(BGR2RGB) → cv2.resize(224×224) → 转为 PIL.Image.fromarray() → transforms.Compose([ToTensor(), Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225])]) → 输入模型。这个链条中PIL 是不可或缺的“翻译官”它确保了 OpenCV 的 numpy array 和 PyTorch 的 float32 tensor 之间数值精度零损失。我们测试过直接用 torch.from_numpy() 转换 OpenCV array因未做归一化模型输出全乱——这就是忽略中间环节的代价。3. 核心模块实现与关键细节解析3.1 数据准备与增强策略小样本下的泛化能力保障花朵识别最大的现实困境是公开数据集如 Oxford 102 Flowers、Stanford Dogs虽有标注但类别固定、背景单一、拍摄角度受限直接拿来训练模型在真实手机拍照场景下泛化极差。我的做法是构建“三层数据金字塔”底层用 Oxford 1028189 张102 类做迁移学习基础中层用 Bing Image Search 爬取的 20 类常见园艺花卉玫瑰、百合、向日葵等每类 300 张人工清洗去水印、裁切主体顶层是学生自己用手机拍摄的 5 类本地花卉含不同光照、遮挡、旋转每类 50 张严格标注。总数据量约 12000 张但关键不在数量而在多样性构造。增强策略必须针对花朵特性定制几何变换随机水平翻转prob0.5——花朵左右对称性高翻转合理但禁用垂直翻转因花茎方向有物理意义随机旋转 ±15°非 ±90°避免花瓣倒置失真色彩扰动Hue ±0.1、Saturation ±0.2、Value ±0.2 ——模拟不同天气和白平衡偏差特别加入CLAHE限制对比度自适应直方图均衡OpenCV 的 cv2.createCLAHE(clipLimit2.0, tileGridSize(8,8)) 对暗部花瓣纹理提升显著遮挡模拟Cutout16×16 随机矩形置零和 RandomErasing概率 0.3区域占比 0.10.3——模拟叶片遮挡、镜头污渍锐化强化用 OpenCV 的 cv2.filter2D(img, -1, kernel) 加入轻微锐化核[[0,-1,0],[-1,5,-1],[0,-1,0]]突出花瓣边缘。验证时发现单纯用 torchvision.transforms.RandomAffine 会导致部分花朵变形失真而定制 CLAHE Cutout 组合使模型在测试集上 mAP 提升 3.2%且对模糊图像鲁棒性增强。数据加载器DataLoader设置 batch_size32num_workers4Windows 下需设 multiprocessing_contextspawn 避免 fork 错误pin_memoryTrue 加速 GPU 传输。这里有个易忽略点验证集必须做与训练集相同的归一化但禁用所有随机增强。我们曾因验证 transform 漏掉 Normalize导致 val_loss 虚低实际部署时准确率暴跌。3.2 MobileNet v2 模型微调从预训练到领域适配的实操要点官方 MobileNet v2 在 ImageNet 上预训练其最后一层 classifier 是 1000 类输出必须替换。但替换方式有讲究直接删掉原有 fc 层接一个 nn.Linear(1280, num_classes)看似简单实则隐患重重。1280 是 v2 最后一个 conv 层的输出通道数但该层后接 Global Average PoolingGAP其输出维度是 (batch, 1280, 1, 1)需 squeeze() 才能喂给 Linear。更稳妥的做法是继承 nn.Module重写 forwardclass FlowerMobileNetV2(nn.Module): def __init__(self, num_classes102, dropout0.2): super().__init__() self.base models.mobilenet_v2(pretrainedTrue) # 冻结前10层特征提取主干 for i, param in enumerate(self.base.features.parameters()): if i 10: param.requires_grad False # 替换分类头 self.classifier nn.Sequential( nn.Dropout(dropout), nn.Linear(1280, 512), nn.ReLU(inplaceTrue), nn.Dropout(dropout), nn.Linear(512, num_classes) ) def forward(self, x): x self.base.features(x) # [B, 1280, 7, 7] x F.adaptive_avg_pool2d(x, (1, 1)) # [B, 1280, 1, 1] x torch.flatten(x, 1) # [B, 1280] return self.classifier(x)冻结前10层是关键经验v2 的 features 有 19 层前10层含 initial conv 和前4个 inverted residual block主要学习边缘、纹理等底层特征对花朵通用后9层学习语义组合需微调。我们对比过全参数微调 vs 冻结微调前者训练 50 epoch 后 val_acc 86.4%但过拟合严重train_acc 98.2%后者 30 epoch 达 85.7%且 loss 曲线平滑。学习率设置也需分层base.features 参数 lr1e-4classifier 参数 lr1e-3用 torch.optim.AdamW 的 param_groups 实现。损失函数选用 LabelSmoothingCrossEntropysmoothing0.1缓解类别不平衡某些花样本少易被忽略。训练时开启混合精度amp可提速 1.8 倍但需确认显卡支持 GTX 1060否则报错。3.3 PyQt6 界面开发从静态布局到动态视频流的无缝集成PyQt6 界面核心是三个组件QLabel显示图像、QPushButton触发动作、QComboBox选择模型。但难点在于实时摄像头流的线程安全更新。若在主线程直接 while True: cap.read() → setPixmap()UI 会完全卡死。正确做法是创建 QThread 子类将摄像头采集封装为独立线程class CameraThread(QThread): frame_ready pyqtSignal(np.ndarray) # 自定义信号传递numpy array def __init__(self, camera_id0): super().__init__() self.camera_id camera_id self.running False def run(self): self.cap cv2.VideoCapture(self.camera_id) self.running True while self.running: ret, frame self.cap.read() if ret: # BGR to RGB frame cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) self.frame_ready.emit(frame) # 发射信号 else: break def stop(self): self.running False if hasattr(self, cap): self.cap.release()主线程中连接信号self.camera_thread CameraThread() self.camera_thread.frame_ready.connect(self.update_frame) # update_frame 是槽函数 self.camera_thread.start()update_frame 函数内用 QImage QPixmap 更新 QLabeldef update_frame(self, frame): h, w, ch frame.shape bytes_per_line ch * w qt_image QImage(frame.data, w, h, bytes_per_line, QImage.Format_RGB888) self.image_label.setPixmap(QPixmap.fromImage(qt_image).scaled( self.image_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))这里有两个关键细节一是 QImage 构造时必须指定 Format_RGB888因 OpenCV 输出 RGB否则颜色错乱二是 scaled() 的 Qt.SmoothTransformation 参数启用双线性插值避免图像缩放锯齿。另外PyQt6 的 QSS 样式表可大幅提升专业感QPushButton { background-color: #4CAF50; color: white; border: none; padding: 8px 16px; font-size: 14px; border-radius: 4px; } QPushButton:hover { background-color: #45a049; } QLabel#result_label { background-color: #f0f0f0; border: 1px solid #ddd; padding: 10px; font-family: Segoe UI, sans-serif; }将样式表 setStyleSheet() 应用到对应 widget界面立刻脱离“学生作业感”。3.4 推理引擎封装CPU 友好型部署的关键参数调优毕设环境大概率无 GPU必须确保 CPU 推理流畅。PyTorch 默认使用单线程需显式启用多线程torch.set_num_threads(4) # 根据 CPU 核心数调整 torch.backends.quantized.engine fbgemm # x86 平台推荐模型导出为 TorchScript 提升 20% 速度model.eval() traced_model torch.jit.trace(model, torch.randn(1, 3, 224, 224)) traced_model.save(flower_mobilenetv2.pt)推理时用 traced_model 而非原始 model。输入预处理必须与训练一致OpenCV 读图 → resize(256×256) → center_crop(224×224) → PIL → ToTensor → Normalize。注意 center_crop 是关键直接 resize 到 224×224 会拉伸变形center_crop 保留原始宽高比裁取中心区域。我们实测过同一张图resize 后推理置信度波动达 ±12%center_crop 波动仅 ±3%。后处理阶段用 torch.topk 获取前3预测with torch.no_grad(): outputs traced_model(input_tensor) probabilities torch.nn.functional.softmax(outputs, dim1) top3_prob, top3_idx torch.topk(probabilities, k3)再通过 class_names 列表映射为中文名。为防界面卡顿推理放在 QThreadPool 中异步执行class InferenceWorker(QRunnable): def __init__(self, model, image_tensor, callback): super().__init__() self.model model self.image_tensor image_tensor self.callback callback def run(self): with torch.no_grad(): outputs self.model(self.image_tensor) probs torch.nn.functional.softmax(outputs, dim1) top3 torch.topk(probs, 3) self.callback(top3) # 回调更新UI这样摄像头流持续采集推理在后台线程跑UI 主线程只负责显示结果帧率稳定在 2224 fps。4. 实操全流程与关键配置详解4.1 环境搭建Anaconda PyTorch CPU 版的稳定组合强烈建议用 Anaconda 管理环境避免 pip 依赖冲突。创建专用环境conda create -n flower-recognition python3.9 conda activate flower-recognitionPyTorch 安装必须匹配 CUDA 版本但毕设优先选 CPU 版省去驱动适配烦恼# 官网查最新 CPU 版命令截至2024年常用 pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpuOpenCV 和 PyQt6 用 conda 安装更稳conda install -c conda-forge opencv pyqt6 pip install pillow # PIL验证安装import torch, cv2, PyQt6, PIL print(fPyTorch {torch.__version__}, CUDA: {torch.cuda.is_available()}) print(fOpenCV {cv2.__version__}, PyQt6 {PyQt6.QtCore.QT_VERSION_STR})常见错误ModuleNotFoundError: No module named cv2多因 conda 和 pip 混用导致。解决方案conda uninstall opencv后pip install opencv-python-headless无 GUI 版本减少依赖ImportError: DLL load failed通常是 PyQt6 与 Qt6 运行库版本不匹配重装conda install -c conda-forge pyqt即可。4.2 数据集构建与目录结构规范标准目录结构如下flower_recognition/ ├── data/ │ ├── train/ # 训练集按类别建文件夹 │ │ ├── rose/ # 每类一个文件夹 │ │ ├── tulip/ │ │ └── ... │ ├── val/ # 验证集结构同 train │ └── test/ # 测试集可选 ├── models/ │ └── mobilenetv2_flower.pth # 训练好的模型 ├── ui/ │ └── main_window.ui # Designer 设计文件可选 ├── utils/ │ ├── dataset.py # 自定义 Dataset 类 │ └── transforms.py # 预处理 pipeline ├── train.py # 训练脚本 ├── infer.py # 推理脚本 └── main.py # PyQt6 主程序自定义 Dataset 必须重写__getitem__返回 image 和 labelclass FlowerDataset(Dataset): def __init__(self, root_dir, transformNone): self.root_dir root_dir self.transform transform self.classes sorted(os.listdir(root_dir)) self.class_to_idx {cls: idx for idx, cls in enumerate(self.classes)} self.samples [] for cls in self.classes: cls_path os.path.join(root_dir, cls) for img_name in os.listdir(cls_path): if img_name.lower().endswith((.png, .jpg, .jpeg)): self.samples.append((os.path.join(cls_path, img_name), self.class_to_idx[cls])) def __getitem__(self, idx): img_path, label self.samples[idx] image cv2.imread(img_path) image cv2.cvtColor(image, cv2.COLOR_BGR2RGB) if self.transform: image self.transform(image) return image, label注意cv2.imread()默认 BGR必须转 RGB否则模型输入错乱。transform 用 torchvision.transforms.Compose 包裹包含 ToTensor 和 Normalize。4.3 训练脚本 train.py 的完整实现与参数说明import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import models from tqdm import tqdm import argparse import os from utils.dataset import FlowerDataset from utils.transforms import get_train_transform, get_val_transform def train_one_epoch(model, dataloader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for images, labels in tqdm(dataloader, descTraining): images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() return running_loss / len(dataloader), 100. * correct / total def validate(model, dataloader, criterion, device): model.eval() running_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in tqdm(dataloader, descValidating): images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) running_loss loss.item() _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() return running_loss / len(dataloader), 100. * correct / total if __name__ __main__: parser argparse.ArgumentParser() parser.add_argument(--data_dir, typestr, defaultdata/train) parser.add_argument(--val_dir, typestr, defaultdata/val) parser.add_argument(--num_classes, typeint, default20) parser.add_argument(--batch_size, typeint, default32) parser.add_argument(--epochs, typeint, default30) parser.add_argument(--lr, typefloat, default1e-3) parser.add_argument(--output_dir, typestr, defaultmodels) args parser.parse_args() device torch.device(cuda if torch.cuda.is_available() else cpu) print(fUsing device: {device}) # 数据集 train_dataset FlowerDataset(args.data_dir, get_train_transform()) val_dataset FlowerDataset(args.val_dir, get_val_transform()) train_loader DataLoader(train_dataset, batch_sizeargs.batch_size, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_sizeargs.batch_size, shuffleFalse, num_workers4, pin_memoryTrue) # 模型 model FlowerMobileNetV2(num_classesargs.num_classes) model model.to(device) # 损失与优化器 criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer optim.AdamW([ {params: model.base.features.parameters(), lr: 1e-4}, {params: model.classifier.parameters(), lr: 1e-3} ]) best_acc 0.0 for epoch in range(args.epochs): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc validate(model, val_loader, criterion, device) print(fEpoch {epoch1}/{args.epochs} | Train Loss: {train_loss:.4f} Acc: {train_acc:.2f}% | Val Loss: {val_loss:.4f} Acc: {val_acc:.2f}%) if val_acc best_acc: best_acc val_acc os.makedirs(args.output_dir, exist_okTrue) torch.save(model.state_dict(), os.path.join(args.output_dir, fmobilenetv2_flower_best_acc_{best_acc:.2f}.pth))关键参数说明--batch_size32兼顾显存占用与梯度稳定性GPU 显存 4G 时可降至 16--epochs30v2 微调收敛快30 epoch 足够再多易过拟合label_smoothing0.1缓解噪声标签影响实测提升小样本类别准确率分层学习率base.features 用 1e-4微调classifier 用 1e-3重训练避免主干网络被破坏。4.4 PyQt6 主程序 main.py 的核心逻辑与打包发布main.py 是整个系统的入口结构清晰import sys import os import torch import cv2 import numpy as np from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QFileDialog from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal from PyQt6.QtGui import QPixmap, QImage from models.flower_mobilenetv2 import FlowerMobileNetV2 # 自定义模型 from utils.transforms import get_inference_transform class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(花朵识别系统) self.setGeometry(100, 100, 1000, 700) # 初始化模型 self.device torch.device(cuda if torch.cuda.is_available() else cpu) self.model FlowerMobileNetV2(num_classes20) self.model.load_state_dict(torch.load(models/mobilenetv2_flower_best_acc_85.70.pth, map_locationself.device)) self.model.eval() self.model.to(self.device) self.transform get_inference_transform() # 加载类别名 self.class_names [玫瑰, 百合, 向日葵, 郁金香, 菊花, ...] # 20类中文名 # 创建UI self.init_ui() # 摄像头线程 self.camera_thread None self.is_camera_running False def init_ui(self): central_widget QWidget() self.setCentralWidget(central_widget) layout QVBoxLayout(central_widget) # 图像显示区 self.image_label QLabel() self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.image_label.setStyleSheet(QLabel { background-color: #f5f5f5; border: 1px solid #ddd; }) layout.addWidget(self.image_label, 1) # 控制区 control_layout QHBoxLayout() self.btn_load QPushButton(加载图片) self.btn_load.clicked.connect(self.load_image) control_layout.addWidget(self.btn_load) self.btn_camera QPushButton(启动摄像头) self.btn_camera.clicked.connect(self.toggle_camera) control_layout.addWidget(self.btn_camera) self.result_label QLabel(识别结果待检测) self.result_label.setObjectName(result_label) self.result_label.setWordWrap(True) control_layout.addWidget(self.result_label) layout.addLayout(control_layout) def load_image(self): file_name, _ QFileDialog.getOpenFileName(self, 选择图片, , Image Files (*.png *.jpg *.jpeg)) if file_name: image cv2.imread(file_name) image cv2.cvtColor(image, cv2.COLOR_BGR2RGB) self.display_image(image) self.infer_image(image) def display_image(self, image): h, w, ch image.shape bytes_per_line ch * w qt_image QImage(image.data, w, h, bytes_per_line, QImage.Format_RGB888) self.image_label.setPixmap(QPixmap.fromImage(qt_image).scaled( self.image_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) def infer_image(self, image): # 预处理 pil_img Image.fromarray(image) input_tensor self.transform(pil_img).unsqueeze(0).to(self.device) # 推理 with torch.no_grad(): outputs self.model(input_tensor) probabilities torch.nn.functional.softmax(outputs, dim1) top3_prob, top3_idx torch.topk(probabilities, k3) # 更新结果 result_text 识别结果\n for i in range(3): cls_name self.class_names[top3_idx[0][i].item()] prob top3_prob[0][i].item() * 100 result_text f{i1}. {cls_name}: {prob:.1f}%\n self.result_label.setText(result_text) def toggle_camera(self): if not self.is_camera_running: self.start_camera() else: self.stop_camera() def start_camera(self): self.camera_thread CameraThread() self.camera_thread.frame_ready.connect(self.process_frame) self.camera_thread.start() self.btn_camera.setText(关闭摄像头) self.is_camera_running True def stop_camera(self): if self.camera_thread and self.camera_thread.isRunning(): self.camera_thread.stop() self.camera_thread.wait() self.btn_camera.setText(启动摄像头) self.is_camera_running False def process_frame(self, frame): self.display_image(frame) # 每3帧推理一次减轻CPU负担 if not hasattr(self, _frame_count): self._frame_count 0 self._frame_count 1 if self._frame_count % 3 0: self.infer_image(frame) if __name__ __main__: app QApplication(sys.argv) window MainWindow() window.show() sys.exit(app.exec())打包发布用 PyInstallerpip install pyinstaller pyinstaller --onefile --windowed --add-data models;models --add-data data;data --iconicon.ico main.py--add-data参数将 models 和 data 目录打包进 exe--windowed隐藏控制台。生成的 dist/main.exe 即可直接运行无需安装 Python 环境。5. 常