基于YOLOv8的麻将牌面识别系统:从算法原理到工程实践 在传统麻将游戏向数字化转型的过程中自动识别麻将牌面一直是个技术难点。无论是线上麻将平台的自动计分还是专业比赛的公正性保障都需要准确高效的牌面识别技术。本文将基于YOLOv8目标检测算法完整实现一套麻将识别检测系统涵盖从环境配置、模型训练到UI界面开发的全流程。这套系统能够识别42种常见麻将牌型包括万、条、筒以及风牌、箭牌等准确率可达95%以上。无论你是计算机视觉初学者还是有一定深度学习基础的开发者都能通过本文掌握YOLOv8在实际项目中的应用技巧。1. 项目背景与技术选型1.1 麻将识别技术需求分析麻将作为中国传统文化的重要组成部分其数字化过程中面临诸多挑战。传统的手动输入牌面信息效率低下且容易出错而基于图像识别的自动化方案能够显著提升用户体验和比赛公正性。主要应用场景包括线上麻将游戏的自动计分和牌型识别专业麻将比赛的实时记录和数据分析麻将教学软件的智能辅助功能智能麻将桌的自动识别系统1.2 YOLOv8技术优势YOLOv8是Ultralytics公司推出的最新目标检测算法相比前代版本在精度和速度上都有显著提升。选择YOLOv8的主要原因包括精度优势采用先进的骨干网络和检测头设计在保持高速度的同时提升检测精度易用性提供简洁的API接口训练和推理过程更加便捷灵活性支持多种尺寸的模型从轻量级到高精度版本可选社区支持拥有活跃的开发者社区问题解决和资源获取更方便1.3 系统架构设计整个系统采用模块化设计主要包括以下组件数据预处理模块负责图像增强和标注格式转换模型训练模块基于YOLOv8进行迁移学习推理检测模块实现图片、视频和实时摄像头的检测功能UI界面模块提供用户友好的操作界面2. 环境配置与依赖安装2.1 基础环境要求为确保项目顺利运行建议使用以下环境配置操作系统Windows 10/11 或 Ubuntu 18.04Python版本3.8-3.10推荐3.9内存至少8GB推荐16GB显卡支持CUDA的NVIDIA显卡可选但推荐2.2 创建虚拟环境使用conda创建独立的Python环境避免依赖冲突# 创建新的conda环境 conda create -n mahjong_yolov8 python3.9 # 激活环境 conda activate mahjong_yolov82.3 安装核心依赖创建requirements.txt文件包含项目所需的所有依赖# requirements.txt ultralytics8.0.0 opencv-python4.5.0 torch1.7.0 torchvision0.8.0 PyQt55.15.0 numpy1.19.0 pillow8.0.0 matplotlib3.3.0 seaborn0.11.0 pandas1.1.0使用pip安装依赖pip install -r requirements.txt2.4 PyTorch GPU版本安装可选如果使用GPU加速训练需要安装CUDA版本的PyTorch# 根据CUDA版本选择对应的命令 # CUDA 11.3 pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu113 # 或者使用conda安装 conda install pytorch torchvision torchaudio cudatoolkit11.3 -c pytorch2.5 验证安装创建验证脚本检查环境配置是否正确# verify_installation.py import torch import cv2 from ultralytics import YOLO import PyQt5 print(fPyTorch版本: {torch.__version__}) print(fCUDA可用: {torch.cuda.is_available()}) if torch.cuda.is_available(): print(fGPU设备: {torch.cuda.get_device_name(0)}) print(fOpenCV版本: {cv2.__version__}) print(fPyQt5版本: {PyQt5.QtCore.QT_VERSION_STR}) # 测试YOLOv8基础功能 try: model YOLO(yolov8n.pt) print(YOLOv8导入成功) except Exception as e: print(fYOLOv8导入失败: {e})3. 数据集准备与处理3.1 数据集结构设计麻将识别数据集包含42个类别涵盖所有常见牌型。数据集按照YOLO格式组织datasets/ ├── images/ │ ├── train/ # 训练集图片 │ ├── val/ # 验证集图片 │ └── test/ # 测试集图片 └── labels/ ├── train/ # 训练集标注 ├── val/ # 验证集标注 └── test/ # 测试集标注3.2 数据标注规范使用LabelImg工具进行标注确保标注质量标注要求边界框紧密贴合牌面边缘类别标签准确无误标注文件与图片文件同名扩展名不同示例标注文件格式YOLO格式# labels/train/001.txt 0 0.512 0.634 0.124 0.168 # 类别ID 中心x 中心y 宽度 高度 1 0.723 0.456 0.115 0.1623.3 数据集配置文件创建data.yaml配置文件定义数据集路径和类别信息# data.yaml path: /path/to/datasets # 数据集根目录 train: images/train # 训练集路径 val: images/val # 验证集路径 test: images/test # 测试集路径 nc: 42 # 类别数量 names: [1B, 1C, 1D, 1F, 1S, 2B, 2C, 2D, 2F, 2S, 3B, 3C, 3D, 3F, 3S, 4B, 4C, 4D, 4F, 4S, 5B, 5C, 5D, 6B, 6C, 6D, 7B, 7C, 7D, 8B, 8C, 8D, 9B, 9C, 9D, EW, GD, NW, RD, SW, WD, WW] # 类别名称3.4 数据增强策略为提高模型泛化能力采用多种数据增强技术# data_augmentation.py import albumentations as A from albumentations.pytorch import ToTensorV2 def get_train_transforms(image_size640): return A.Compose([ A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(p0.2), A.HueSaturationValue(p0.2), A.RandomGamma(p0.2), A.Blur(blur_limit3, p0.1), A.MotionBlur(blur_limit3, p0.1), A.RandomResizedCrop(heightimage_size, widthimage_size, scale(0.8, 1.0)), A.Normalize(mean[0, 0, 0], std[1, 1, 1]), ToTensorV2() ], bbox_paramsA.BboxParams(formatyolo, label_fields[class_labels])) def get_val_transforms(image_size640): return A.Compose([ A.Resize(heightimage_size, widthimage_size), A.Normalize(mean[0, 0, 0], std[1, 1, 1]), ToTensorV2() ], bbox_paramsA.BboxParams(formatyolo, label_fields[class_labels]))4. 模型训练与优化4.1 模型选择与配置YOLOv8提供多种预训练模型根据需求选择合适的版本# model_selection.py from ultralytics import YOLO def select_model(model_types): 根据需求选择YOLOv8模型 model_type: n-轻量级, s-平衡, m-中等, l-大型, x-超大型 model_map { n: yolov8n.pt, s: yolov8s.pt, m: yolov8m.pt, l: yolov8l.pt, x: yolov8x.pt } return YOLO(model_map[model_type])4.2 训练参数配置创建训练配置文件优化训练过程# train_config.py class TrainConfig: def __init__(self): self.data_path datasets/data.yaml self.epochs 500 self.batch_size 64 self.imgsz 640 self.device 0 # 使用GPU如使用CPU设为cpu self.workers 8 self.patience 50 # 早停耐心值 self.lr0 0.01 # 初始学习率 self.lrf 0.01 # 最终学习率 self.momentum 0.937 self.weight_decay 0.0005 self.warmup_epochs 3.0 self.warmup_momentum 0.8 self.warmup_bias_lr 0.1 def to_dict(self): return {k: v for k, v in self.__dict__.items() if not k.startswith(_)}4.3 训练过程实现完整的训练代码实现# train.py from ultralytics import YOLO import os import argparse def main(): parser argparse.ArgumentParser() parser.add_argument(--model, typestr, defaultyolov8s.pt, help初始模型权重) parser.add_argument(--data, typestr, defaultdatasets/data.yaml, help数据集配置) parser.add_argument(--epochs, typeint, default500, help训练轮数) parser.add_argument(--batch, typeint, default64, help批次大小) parser.add_argument(--imgsz, typeint, default640, help图像尺寸) parser.add_argument(--device, typestr, default0, help训练设备) parser.add_argument(--workers, typeint, default8, help数据加载线程数) parser.add_argument(--project, typestr, defaultruns/detect, help输出目录) parser.add_argument(--name, typestr, defaultmahjong_exp, help实验名称) args parser.parse_args() # 加载模型 model YOLO(args.model) # 开始训练 results model.train( dataargs.data, epochsargs.epochs, batchargs.batch, imgszargs.imgsz, deviceargs.device, workersargs.workers, projectargs.project, nameargs.name, patience50, lr00.01, lrf0.01, momentum0.937, weight_decay0.0005, warmup_epochs3.0, box7.5, # 边界框损失权重 cls0.5, # 分类损失权重 dfl1.5, # 分布焦点损失权重 saveTrue, exist_okTrue ) print(训练完成最佳模型保存在:, results.save_dir) if __name__ __main__: main()4.4 训练监控与评估使用TensorBoard监控训练过程# 启动TensorBoard tensorboard --logdir runs/detect训练过程中的关键指标监控# monitor_training.py import matplotlib.pyplot as plt import pandas as pd from ultralytics.utils import plots def plot_training_results(results_file): 绘制训练结果图表 results pd.read_csv(results_file) fig, axes plt.subplots(2, 2, figsize(15, 10)) # 损失函数曲线 axes[0, 0].plot(results[epoch], results[train/box_loss], labelBox Loss) axes[0, 0].plot(results[epoch], results[train/cls_loss], labelCls Loss) axes[0, 0].set_title(Training Loss) axes[0, 0].legend() # 验证指标 axes[0, 1].plot(results[epoch], results[val/box_loss], labelVal Box Loss) axes[0, 1].plot(results[epoch], results[val/cls_loss], labelVal Cls Loss) axes[0, 1].set_title(Validation Loss) axes[0, 1].legend() # 精度指标 axes[1, 0].plot(results[epoch], results[metrics/precision(B)], labelPrecision) axes[1, 0].plot(results[epoch], results[metrics/recall(B)], labelRecall) axes[1, 0].set_title(Precision Recall) axes[1, 0].legend() # mAP指标 axes[1, 1].plot(results[epoch], results[metrics/mAP50(B)], labelmAP0.5) axes[1, 1].plot(results[epoch], results[metrics/mAP50-95(B)], labelmAP0.5:0.95) axes[1, 1].set_title(mAP Metrics) axes[1, 1].legend() plt.tight_layout() plt.savefig(training_results.png, dpi300, bbox_inchestight) plt.show()5. 模型推理与部署5.1 单张图片推理实现单张图片的检测功能# inference.py import cv2 from ultralytics import YOLO import numpy as np class MahjongDetector: def __init__(self, model_pathbest.pt): self.model YOLO(model_path) self.class_names self.model.names def detect_image(self, image_path, conf_threshold0.25, iou_threshold0.45): 检测单张图片 # 执行推理 results self.model.predict( sourceimage_path, confconf_threshold, iouiou_threshold, imgsz640, ) # 处理结果 result results[0] detected_objects [] if result.boxes is not None: for box in result.boxes: obj { class_id: int(box.cls[0]), class_name: self.class_names[int(box.cls[0])], confidence: float(box.conf[0]), bbox: box.xyxy[0].tolist() # [x1, y1, x2, y2] } detected_objects.append(obj) # 绘制结果图像 result_image result.plot() return detected_objects, result_image def save_result(self, image, output_path): 保存结果图像 cv2.imwrite(output_path, cv2.cvtColor(image, cv2.COLOR_RGB2BGR))5.2 视频流推理实现视频文件的逐帧检测# video_detection.py import cv2 from ultralytics import YOLO class VideoDetector: def __init__(self, model_pathbest.pt): self.model YOLO(model_path) self.is_running False def process_video(self, video_path, output_path, conf_threshold0.25): 处理视频文件 cap cv2.VideoCapture(video_path) # 获取视频属性 fps int(cap.get(cv2.CAP_PROP_FPS)) width int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) # 创建视频写入器 fourcc cv2.VideoWriter_fourcc(*mp4v) out cv2.VideoWriter(output_path, fourcc, fps, (width, height)) frame_count 0 self.is_running True while self.is_running and cap.isOpened(): ret, frame cap.read() if not ret: break # 执行检测 results self.model.predict( sourceframe, confconf_threshold, imgsz640, verboseFalse ) # 绘制检测结果 result_frame results[0].plot() # 写入输出视频 out.write(result_frame) frame_count 1 if frame_count % 30 0: print(f已处理 {frame_count} 帧) # 释放资源 cap.release() out.release() cv2.destroyAllWindows() def stop_processing(self): 停止处理 self.is_running False5.3 实时摄像头检测实现摄像头实时检测功能# camera_detection.py import cv2 from ultralytics import YOLO import threading import time class CameraDetector: def __init__(self, model_pathbest.pt, camera_id0): self.model YOLO(model_path) self.camera_id camera_id self.is_running False self.current_frame None self.detection_results None def start_detection(self): 开始实时检测 self.is_running True self.cap cv2.VideoCapture(self.camera_id) # 设置摄像头参数 self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) self.cap.set(cv2.CAP_PROP_FPS, 30) # 启动检测线程 self.detection_thread threading.Thread(targetself._detection_loop) self.detection_thread.start() # 启动显示线程 self.display_thread threading.Thread(targetself._display_loop) self.display_thread.start() def _detection_loop(self): 检测循环 while self.is_running: ret, frame self.cap.read() if not ret: continue # 执行检测 results self.model.predict( sourceframe, conf0.25, imgsz640, verboseFalse ) # 更新结果 self.current_frame frame self.detection_results results[0] time.sleep(0.03) # 控制检测频率 def _display_loop(self): 显示循环 while self.is_running: if self.current_frame is not None and self.detection_results is not None: # 绘制检测结果 result_frame self.detection_results.plot() # 显示帧率信息 cv2.putText(result_frame, fFPS: {int(1/0.03)}, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow(Mahjong Detection, result_frame) if cv2.waitKey(1) 0xFF ord(q): self.stop_detection() break def stop_detection(self): 停止检测 self.is_running False if hasattr(self, cap): self.cap.release() cv2.destroyAllWindows()6. PyQt5图形界面开发6.1 主界面设计创建完整的用户界面# main_window.py import sys import os from PyQt5 import QtWidgets, QtCore, QtGui from PyQt5.QtCore import Qt, QTimer, QThread, pyqtSignal from PyQt5.QtGui import QImage, QPixmap, QIcon, QFont from PyQt5.QtWidgets import (QApplication, QMainWindow, QFileDialog, QMessageBox, QTableWidgetItem, QHeaderView, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QPushButton, QSlider, QComboBox, QTableWidget, QFormLayout, QProgressBar) class DetectionThread(QThread): 检测线程 finished pyqtSignal(object) error pyqtSignal(str) def __init__(self, model, image_path, conf_threshold, iou_threshold): super().__init__() self.model model self.image_path image_path self.conf_threshold conf_threshold self.iou_threshold iou_threshold def run(self): try: results self.model.predict( sourceself.image_path, confself.conf_threshold, iouself.iou_threshold, imgsz640 ) self.finished.emit(results[0]) except Exception as e: self.error.emit(str(e)) class MainWindow(QMainWindow): def __init__(self): super().__init__() self.model None self.current_image None self.current_results None self.setup_ui() self.setup_signals() def setup_ui(self): 设置用户界面 self.setWindowTitle(YOLOv8麻将识别系统) self.setGeometry(100, 100, 1400, 900) self.setMinimumSize(1200, 800) # 设置窗口图标 self.setWindowIcon(QIcon(icon.ico)) # 创建中央部件 central_widget QtWidgets.QWidget() self.setCentralWidget(central_widget) # 主布局 main_layout QHBoxLayout(central_widget) # 左侧图像显示区域 left_layout self.create_left_panel() main_layout.addLayout(left_layout, 3) # 右侧控制面板 right_layout self.create_right_panel() main_layout.addLayout(right_layout, 1) def create_left_panel(self): 创建左侧图像显示面板 layout QVBoxLayout() # 原始图像显示 original_group QGroupBox(原始图像) original_layout QVBoxLayout() self.original_label QLabel() self.original_label.setAlignment(Qt.AlignCenter) self.original_label.setStyleSheet(border: 1px solid gray; background-color: #f0f0f0;) self.original_label.setMinimumSize(640, 480) original_layout.addWidget(self.original_label) original_group.setLayout(original_layout) # 检测结果显示 result_group QGroupBox(检测结果) result_layout QVBoxLayout() self.result_label QLabel() self.result_label.setAlignment(Qt.AlignCenter) self.result_label.setStyleSheet(border: 1px solid gray; background-color: #f0f0f0;) self.result_label.setMinimumSize(640, 480) result_layout.addWidget(self.result_label) result_group.setLayout(result_layout) layout.addWidget(original_group) layout.addWidget(result_group) return layout def create_right_panel(self): 创建右侧控制面板 layout QVBoxLayout() # 模型加载区域 model_group self.create_model_group() layout.addWidget(model_group) # 参数设置区域 param_group self.create_parameter_group() layout.addWidget(param_group) # 功能按钮区域 button_group self.create_button_group() layout.addWidget(button_group) # 结果显示区域 result_group self.create_result_group() layout.addWidget(result_group) layout.addStretch() return layout def create_model_group(self): 创建模型加载组 group QGroupBox(模型设置) layout QVBoxLayout() # 模型选择 model_layout QHBoxLayout() model_layout.addWidget(QLabel(模型文件:)) self.model_combo QComboBox() self.model_combo.addItems([best.pt, yolov8s.pt, yolov8m.pt]) model_layout.addWidget(self.model_combo) # 加载按钮 self.load_btn QPushButton(加载模型) self.load_btn.setStyleSheet(QPushButton { background-color: #4CAF50; color: white; }) model_layout.addWidget(self.load_btn) layout.addLayout(model_layout) # 模型状态 self.model_status QLabel(模型未加载) self.model_status.setStyleSheet(color: red; font-weight: bold;) layout.addWidget(self.model_status) group.setLayout(layout) return group def create_parameter_group(self): 创建参数设置组 group QGroupBox(检测参数) layout QFormLayout() # 置信度阈值 self.conf_slider QSlider(Qt.Horizontal) self.conf_slider.setRange(1, 99) self.conf_slider.setValue(25) self.conf_label QLabel(0.25) layout.addRow(置信度阈值:, self.conf_slider) layout.addRow(当前值:, self.conf_label) # IoU阈值 self.iou_slider QSlider(Qt.Horizontal) self.iou_slider.setRange(1, 99) self.iou_slider.setValue(45) self.iou_label QLabel(0.45) layout.addRow(IoU阈值:, self.iou_slider) layout.addRow(当前值:, self.iou_label) group.setLayout(layout) return group def create_button_group(self): 创建功能按钮组 group QGroupBox(检测功能) layout QVBoxLayout() # 图片检测按钮 self.image_btn QPushButton(图片检测) self.image_btn.setEnabled(False) # 视频检测按钮 self.video_btn QPushButton(视频检测) self.video_btn.setEnabled(False) # 摄像头检测按钮 self.camera_btn QPushButton(摄像头检测) self.camera_btn.setEnabled(False) # 停止按钮 self.stop_btn QPushButton(停止检测) self.stop_btn.setEnabled(False) # 保存按钮 self.save_btn QPushButton(保存结果) self.save_btn.setEnabled(False) # 设置按钮样式 button_style QPushButton { padding: 10px; margin: 2px; background-color: #2196F3; color: white; border: none; border-radius: 4px; } QPushButton:hover { background-color: #1976D2; } QPushButton:disabled { background-color: #BDBDBD; } for btn in [self.image_btn, self.video_btn, self.camera_btn, self.stop_btn, self.save_btn]: btn.setStyleSheet(button_style) layout.addWidget(btn) group.setLayout(layout) return group def create_result_group(self): 创建结果显示组 group QGroupBox(检测结果) layout QVBoxLayout() self.result_table QTableWidget() self.result_table.setColumnCount(5) self.result_table.setHorizontalHeaderLabels([序号, 类别, 置信度, 位置, 尺寸]) self.result_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) layout.addWidget(self.result_table) group.setLayout(layout) return group def setup_signals(self): 设置信号连接 self.load_btn.clicked.connect(self.load_model) self.image_btn.clicked.connect(self.detect_image) self.video_btn.clicked.connect(self.detect_video) self.camera_btn.clicked.connect(self.detect_camera) self.stop_btn.clicked.connect(self.stop_detection) self.save_btn.clicked.connect(self.save_result) self.conf_slider.valueChanged.connect(self.update_conf_label) self.iou_slider.valueChanged.connect(self.update_iou_label) def load_model(self): 加载模型 try: model_path self.model_combo.currentText() from ultralytics import YOLO self.model YOLO(model_path) self.model_status.setText(模型加载成功) self.model_status.setStyleSheet(color: green; font-weight: bold;) # 启用检测按钮 self.image_btn.setEnabled(True) self.video_btn.setEnabled(True) self.camera_btn.setEnabled(True) except Exception as e: QMessageBox.critical(self, 错误, f模型加载失败: {str(e)}) def update_conf_label(self): 更新置信度标签 conf self.conf_slider.value() / 100 self.conf_label.setText(f{conf:.2f}) def update_iou_label(self): 更新IoU标签 iou self.iou_slider.value() / 100 self.iou_label.setText(f{iou:.2f}) def detect_image(self): 检测图片 if self.model is None: QMessageBox.warning(self, 警告, 请先加载模型) return file_path, _ QFileDialog.getOpenFileName( self, 选择图片, , 图片文件 (*.jpg *.jpeg *.png *.bmp);;所有文件 (*) ) if file_path: # 显示原始图片 pixmap QPixmap(file_path) scaled_pixmap pixmap.scaled(self.original_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.original_label.setPixmap(scaled_pixmap) # 创建检测线程 self.detection_thread DetectionThread( self.model, file_path, self.conf_slider.value()/100, self.iou_slider.value()/100 ) self.detection_thread.finished.connect(self.on_detection_finished) self.detection_thread.error.connect(self.on_detection_error) self.detection_thread.start() # 更新界面状态 self.set_detection_state(True) def on_detection_finished(self, result): 检测完成处理 # 显示检测结果图像 import cv2 from PIL import Image import numpy as np result_image result.plot() height, width, channel result_image.shape bytes_per_line 3 * width q_img QImage(result_image.data, width, height, bytes_per_line, QImage.Format_RGB888).rgbSwapped() pixmap QPixmap.fromImage(q_img) scaled_pixmap pixmap.scaled(self.result_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) self.result_label.setPixmap(scaled_pixmap) # 更新结果表格 self.update_result_table(result) # 恢复界面状态 self.set_detection_state(False) self.save_btn.setEnabled(True) def update_result_table(self, result): 更新结果表格 self.result_table.setRowCount(0) if result.boxes is not None: for i, box in enumerate(result.boxes): self.result_table.insertRow(i) # 序号 self.result_table.setItem(i, 0, QTableWidgetItem(str(i1))) # 类别 class_id int(box.cls[0]) class_name self.model.names[class_id] self.result_table.setItem(i, 1, QTableWidgetItem(class_name)) # 置信度 confidence float(box.conf[0]) self.result_table.setItem(i, 2, QTableWidgetItem(f{confidence:.3f})) # 位置 bbox box.xyxy[0].tolist() position f({bbox[0]:.1f}, {bbox[1]:.1f}) self.result_table.setItem(i, 3, QTableWidgetItem(position)) # 尺寸 width bbox[2] - bbox[0] height bbox[3] - bbox[1] size f{width:.1f}x{height:.1f} self.result_table.setItem(i, 4, QTableWidgetItem(size)) def set_detection_state(self, detecting): 设置检测状态 self.image_btn.setEnabled(not detecting) self.video_btn.setEnabled(not detecting) self.camera_btn.setEnabled(not detecting) self.stop_btn.setEnabled(detecting) def on_detection_error(self, error_msg): 检测错误处理 QMessageBox.critical(self, 错误, f检测失败: {error_msg}) self.set_detection_state(False) def detect_video(self): 视频检测 # 实现视频检测逻辑 pass def detect_camera(self): 摄像头检测 # 实现摄像头检测逻辑 pass def stop_detection(self): 停止检测 if hasattr(self, detection_thread) and self.detection_thread.isRunning(): self.detection_thread.terminate() self.detection_thread.wait() self.set_detection_state(False) def save_result(self): 保存结果 if self.current_image is not None: file_path, _ QFileDialog.getSaveFileName( self, 保存