ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于YOLOv8与PyQt5的路面坑洞检测系统实战:从训练到部署

基于YOLOv8与PyQt5的路面坑洞检测系统实战:从训练到部署 1. 路面坑洞检测系统整体设计思路拆解1.1 为什么选择YOLOv8而不是传统图像处理方法做路面坑洞检测这件事我最早试过用传统的边缘检测加阈值分割。OpenCV的Canny算子配合形态学操作在光照均匀、坑洞边缘清晰的理想条件下确实能跑出结果。但实际道路场景太复杂了柏油路面的纹理本身就是高频噪声阴影和坑洞在灰度图上几乎无法区分下雨后积水反光更是直接让阈值法失效。我印象很深的一次测试同一段路早上八点和下午四点拍的两张照片传统方法给出的检测结果差了将近40%。后来转向深度学习方案YOLOv8是我对比了Faster R-CNN、SSD和YOLOv5之后定下来的。原因很直接Faster R-CNN精度够但推理速度太慢GTX 1660 Ti上单帧要200ms以上做视频流实时检测根本扛不住SSD对小目标检测效果一般而路面坑洞在车载摄像头画面里往往只占几十个像素YOLOv5虽然成熟但YOLOv8在相同参数量下mAP普遍高出2到3个百分点而且Ultralytics把训练、验证、导出、部署的链路整合得非常顺滑省去了大量造轮子的时间。YOLOv8的Anchor-Free设计对坑洞检测特别友好。坑洞的形状极不规则有的细长、有的接近圆形、有的连成一片预设Anchor Box很难覆盖所有形态。Anchor-Free直接回归中心点和宽高省去了聚类Anchor的步骤在小数据集上表现更稳定。我自己的数据集只有3000多张标注图用YOLOv8n训练100个epoch就能达到0.82的mAP0.5这个成绩在传统方法上想都不敢想。1.2 PyQt5作为界面框架的取舍逻辑检测模型跑通之后下一步是把它包装成一个普通人能用的桌面工具。这里选PyQt5而不是Tkinter或者Web方案有几个实际考量。Tkinter太简陋了做个带视频预览、参数调节、结果导出的界面会非常痛苦控件样式也很难看。Web方案Flask 前端虽然灵活但部署时要开浏览器、配端口对于工地现场那种“双击exe就能用”的需求来说太重了。PyQt5是Python生态里最成熟的桌面GUI框架控件丰富、文档齐全、和OpenCV/NumPy的配合非常自然而且PyInstaller打包成exe的流程已经很成熟。还有一个关键点PyQt5的QThread机制。视频检测是计算密集型任务如果直接在主线程里跑推理界面会直接卡死点任何按钮都没反应。PyQt5的信号槽机制配合QThread可以把推理放在子线程主线程只负责界面刷新用户体验流畅很多。这个设计在后面实际部署时救了我好几次。1.3 系统整体架构与数据流整个系统的数据流是这样的用户通过界面选择视频文件或打开摄像头帧数据进入推理线程YOLOv8模型对每一帧做检测检测结果边界框、置信度、类别通过信号槽传回主线程主线程在QGraphicsView上绘制标注框和统计信息同时把结果写入日志和CSV文件。架构上分三层界面层负责交互和展示推理层封装YOLOv8的加载和推理数据层处理视频读写、结果存储和日志记录。三层之间通过信号槽和回调函数通信耦合度低后面换模型或者改界面都不会互相影响。注意不要把所有逻辑塞进一个MainWindow类里。我第一版就是这么干的后来想加一个“导出检测报告”的功能发现代码已经乱到改不动了只能重构。建议从一开始就分模块。2. 环境搭建与核心依赖配置实操2.1 Python环境与CUDA版本选择环境配置这一步踩的坑最多我先说结论Python 3.9 CUDA 11.8 cuDNN 8.6 PyTorch 2.0.1这个组合在我这里最稳定。GTX 1660 Ti虽然不算新卡但6GB显存跑YOLOv8n和YOLOv8s完全够用batch size设8或者16都没问题。为什么选CUDA 11.8而不是更新的12.x因为PyTorch对CUDA 11.8的支持最成熟很多预编译包都是基于这个版本。你要是用CUDA 12.x可能会遇到PyTorch版本和CUDA不匹配的问题报错信息还特别隐晦。我试过一次用CUDA 12.1配PyTorch 2.1训练时loss直接变成NaN查了半天才发现是版本兼容性问题。安装命令如下用conda建一个独立环境conda create -n pothole python3.9 conda activate pothole pip install torch2.0.1 torchvision0.15.2 --index-url https://download.pytorch.org/whl/cu118 pip install ultralytics8.0.200 pip install PyQt55.15.9 pip install opencv-python4.8.1.78验证GPU是否可用import torch print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果输出True和你的显卡型号说明环境没问题。如果输出False先检查显卡驱动版本GTX 1660 Ti需要驱动版本至少515以上。2.2 PyQt5安装中的常见坑PyQt5安装本身不复杂但有几个坑值得单独说。第一个是安装时长问题pip install PyQt5有时候会卡在下载环节特别是网络不好的时候。我的经验是加个国内镜像源pip install PyQt5 -i https://pypi.tuna.tsinghua.edu.cn/simple第二个坑是OpenGL导致界面无显示。这个问题在远程桌面或者虚拟机里特别常见PyQt5默认用OpenGL渲染但某些环境下OpenGL驱动不完整界面就一片空白。解决办法是在代码开头设置软件渲染import os os.environ[QT_OPENGL] software或者在主函数里加from PyQt5.QtCore import Qt Qt.QCoreApplication.setAttribute(Qt.AA_UseSoftwareOpenGL)第三个坑是高分辨率屏幕适配。现在很多笔记本是2K甚至4K屏PyQt5默认的DPI缩放策略会导致界面元素特别小或者模糊。在创建QApplication之前加这两行QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)2.3 项目目录结构设计目录结构看着是小事但后面代码多了就知道重要性了。我现在的结构是这样的pothole_detection/ ├── models/ │ └── yolov8n_pothole.pt ├── ui/ │ ├── main_window.py │ └── main_window.ui ├── core/ │ ├── detector.py │ ├── video_thread.py │ └── utils.py ├── data/ │ ├── images/ │ ├── labels/ │ └── dataset.yaml ├── output/ │ ├── logs/ │ └── results/ ├── main.py └── requirements.txtcore/detector.py封装YOLOv8的加载和推理core/video_thread.py是QThread子类ui/main_window.py只管界面逻辑。这样分工之后改模型不用动界面代码改界面不用碰推理逻辑。3. 数据集准备与YOLOv8训练全流程3.1 路面坑洞数据采集与标注策略数据集的质量直接决定模型上限。我一开始从网上找了一些公开的坑洞数据集但发现一个问题不同来源的图片分辨率、拍摄角度、光照条件差异太大直接混在一起训练模型很难收敛。后来我决定自己采集用手机固定在车前挡风玻璃上在不同路段、不同时间段拍了大约5000张原始图片。采集时注意几个点第一覆盖不同光照条件早上、中午、傍晚、阴天、雨天都要有否则模型在特定光照下会失效第二包含不同路面类型柏油路、水泥路、有标线的、没标线的第三坑洞大小要多样从拳头大到脸盆大都要有这样模型才能学到尺度不变性。标注用LabelImg格式选YOLO格式txt文件每行是class_id x_center y_center width height坐标归一化到0到1。标注时有个经验边界框不要贴得太紧稍微留一点余量因为坑洞边缘往往有破损过渡区贴太紧反而让模型学到模糊的边界特征。我一般让框比实际坑洞大5到10个像素。标注完成后做数据清洗把以下几类图片剔除模糊到看不清坑洞的、坑洞被车辆遮挡超过50%的、标注框明显错误的。清洗完剩下大约3200张有效图片按8:1:1划分训练集、验证集、测试集。3.2 dataset.yaml配置与训练参数设置dataset.yaml文件长这样path: ./data train: images/train val: images/val test: images/test names: 0: pothole就一个类别简单直接。如果你的数据里还有裂缝、修补痕迹等其他路面病害可以加类别但要注意类别不平衡问题。训练命令用Ultralytics的CLIyolo detect train datadataset.yaml modelyolov8n.pt epochs100 imgsz640 batch16 lr00.01 patience20几个关键参数解释一下。imgsz640是输入分辨率GTX 1660 Ti跑640完全没问题如果你想更快可以降到416但小坑洞的检测精度会下降。batch16是根据6GB显存定的如果显存不够就降到8。lr00.01是初始学习率YOLOv8默认用余弦退火策略这个值在大多数情况下都合适。patience20是早停耐心值如果20个epoch验证集指标没提升就自动停止省时间。如果你想用Python脚本训练方便记录参数from ultralytics import YOLO model YOLO(yolov8n.pt) results model.train( datadataset.yaml, epochs100, imgsz640, batch16, lr00.01, patience20, device0, projectruns/train, namepothole_v1 )3.3 训练过程监控与损失函数曲线解读训练启动后Ultralytics会在runs/train/pothole_v1/目录下生成一堆文件其中results.csv记录了每个epoch的损失和指标。我习惯用pandas读出来画图import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(runs/train/pothole_v1/results.csv) df.columns df.columns.str.strip() fig, axes plt.subplots(1, 2, figsize(14, 5)) axes[0].plot(df[epoch], df[train/box_loss], labeltrain box loss) axes[0].plot(df[epoch], df[val/box_loss], labelval box loss) axes[0].set_xlabel(Epoch) axes[0].set_ylabel(Box Loss) axes[0].legend() axes[1].plot(df[epoch], df[metrics/mAP50(B)], labelmAP0.5) axes[1].plot(df[epoch], df[metrics/mAP50-95(B)], labelmAP0.5:0.95) axes[1].set_xlabel(Epoch) axes[1].set_ylabel(mAP) axes[1].legend() plt.tight_layout() plt.savefig(training_curves.png, dpi150)看曲线有几个判断标准。训练损失和验证损失同步下降说明模型在正常学习如果训练损失降但验证损失升说明过拟合了需要加数据增强或者减模型复杂度如果两个损失都震荡不降可能是学习率太大或者数据标注有问题。我这次训练到第78个epoch时验证mAP达到最高0.823之后开始轻微过拟合早停机制在第98个epoch触发最终用的第78个epoch的权重。3.4 模型评估与改进方向训练完成后用验证集跑评估yolo detect val modelruns/train/pothole_v1/weights/best.pt datadataset.yaml输出会给出mAP0.5、mAP0.5:0.95、精确率、召回率等指标。我的模型精确率0.85、召回率0.79召回率偏低说明有一些坑洞漏检了。分析漏检样本发现主要是远处的小坑洞和被阴影覆盖的坑洞。针对这两个问题我做了两个改进。第一在数据增强里加了mosaic1.0和scale0.5让模型多见一些小目标第二尝试了YOLOv8的ASFFAdaptive Spatial Feature Fusion改进把不同尺度的特征图做自适应融合对小目标检测有提升。改进后召回率提到0.84mAP0.5到0.86。如果你也想做轻量化改进可以试试把Backbone换成MobileNetV3或者ShuffleNetV2参数量能降一半推理速度提升30%左右精度损失在2个百分点以内。对于嵌入式部署场景比如RK3588这个取舍是值得的。4. PyQt5界面设计与多线程推理实现4.1 界面布局与QSS样式美化界面设计我用Qt Designer画好.ui文件然后用pyuic5转成Python代码pyuic5 -x main_window.ui -o main_window.py主界面分三个区域左边是视频显示区用QLabel或者QGraphicsView右边是控制面板放模型选择、置信度阈值滑块、IOU阈值滑块、开始/停止按钮底部是状态栏和日志输出区。QSS样式可以让界面看起来不那么“原生丑”QMainWindow { background-color: #2b2b2b; } QPushButton { background-color: #4a90d9; color: white; border-radius: 6px; padding: 8px 16px; font-size: 14px; } QPushButton:hover { background-color: #5aa0e9; } QSlider::groove:horizontal { height: 6px; background: #555; border-radius: 3px; } QSlider::handle:horizontal { background: #4a90d9; width: 16px; margin: -5px 0; border-radius: 8px; }视频显示区我推荐用QGraphicsView而不是QLabel因为QGraphicsView支持缩放和拖拽用户想看坑洞细节时可以放大。把OpenCV的BGR帧转成QImage再转QPixmap设置到QGraphicsScene上def cvimg_to_qpixmap(cv_img): height, width, channel cv_img.shape bytes_per_line 3 * width q_img QImage(cv_img.data, width, height, bytes_per_line, QImage.Format_RGB888).rgbSwapped() return QPixmap.fromImage(q_img)4.2 QThread推理线程与信号槽通信推理线程是整个系统的核心。我定义一个VideoThread类继承QThread在run方法里循环读帧、推理、发信号class VideoThread(QThread): frame_signal pyqtSignal(np.ndarray) result_signal pyqtSignal(list) stats_signal pyqtSignal(dict) finished_signal pyqtSignal() def __init__(self, model_path, source, conf0.5, iou0.45): super().__init__() self.model YOLO(model_path) self.source source self.conf conf self.iou iou self._running True def run(self): cap cv2.VideoCapture(self.source) frame_count 0 pothole_count 0 while self._running and cap.isOpened(): ret, frame cap.read() if not ret: break results self.model(frame, confself.conf, iouself.iou, verboseFalse) annotated results[0].plot() boxes results[0].boxes current_count len(boxes) if boxes is not None else 0 pothole_count current_count frame_count 1 self.frame_signal.emit(annotated) self.result_signal.emit(boxes.data.cpu().numpy().tolist() if boxes is not None else []) self.stats_signal.emit({ frame: frame_count, current: current_count, total: pothole_count }) cap.release() self.finished_signal.emit() def stop(self): self._running False self.wait()主线程里连接信号self.thread VideoThread(model_path, source, conf, iou) self.thread.frame_signal.connect(self.update_frame) self.thread.stats_signal.connect(self.update_stats) self.thread.finished_signal.connect(self.on_finished) self.thread.start()注意不要在子线程里直接操作UI控件所有UI更新必须通过信号槽回到主线程。我见过有人直接在run方法里调self.label.setPixmap()程序直接崩溃。4.3 置信度阈值与IOU阈值的动态调节界面上放两个QSlider一个控制置信度阈值0.1到0.9一个控制IOU阈值0.1到0.9。滑动时实时更新推理参数def on_conf_changed(self, value): self.conf value / 100.0 self.conf_label.setText(f{self.conf:.2f}) if self.thread and self.thread.isRunning(): self.thread.conf self.conf这里有个细节修改参数时不要重启线程直接改线程对象的属性就行因为推理循环每次都会读最新的值。重启线程会导致视频从头开始体验很差。置信度阈值调低比如0.3能检出更多坑洞但误检也会增加调高比如0.7误检少但可能漏检。实际使用时我建议默认0.5然后根据场景微调。IOU阈值主要影响重叠框的合并坑洞密集时调低一点0.3到0.4能避免框被合并掉。4.4 检测结果可视化与统计面板可视化部分results[0].plot()已经帮我们画好了框和置信度直接显示就行。但如果你想自定义样式可以手动绘制for box in boxes: x1, y1, x2, y2 map(int, box.xyxy[0]) conf float(box.conf[0]) color (0, 255, 0) if conf 0.7 else (0, 255, 255) if conf 0.5 else (0, 0, 255) cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) cv2.putText(frame, f{conf:.2f}, (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)统计面板显示当前帧坑洞数、累计坑洞数、处理帧率。帧率计算用滑动窗口self.frame_times.append(time.time()) if len(self.frame_times) 30: self.frame_times.pop(0) fps len(self.frame_times) / (self.frame_times[-1] - self.frame_times[0])5. 模型部署优化与性能调优实战5.1 ONNX导出与推理加速PyTorch模型直接推理在GTX 1660 Ti上大概25到30 FPS对于实时检测够用但不算快。导出成ONNX再用ONNXRuntime推理能提到40 FPS左右yolo export modelruns/train/pothole_v1/weights/best.pt formatonnx opset12 simplifyTrue推理代码改成import onnxruntime as ort session ort.InferenceSession(best.onnx, providers[CUDAExecutionProvider])ONNX的优势是跨平台后面如果要部署到RK3588这类嵌入式设备ONNX是必经之路。RK3588有NPU需要把ONNX转成RKNN格式用RKNN-Toolkit2做转换和量化。量化到INT8后模型体积缩小到原来的四分之一推理速度提升3到5倍精度损失在1到2个百分点。5.2 TensorRT部署与C集成思路如果你追求极致性能TensorRT是绕不开的。GTX 1660 Ti支持TensorRT 8.6把ONNX转成TensorRT引擎trtexec --onnxbest.onnx --saveEnginebest.engine --fp16FP16精度下推理速度能到60 FPS以上。C集成的话用TensorRT的C API加载引擎配合OpenCV做前后处理。这块工作量不小但如果你要做产品级部署值得投入。5.3 界面卡顿与内存泄漏排查跑长时间视频时我遇到过两个问题。第一个是界面越来越卡查下来是QImage对象没有及时释放每帧都新建QImage导致内存堆积。解决办法是复用QImage缓冲区或者手动调del。第二个是内存泄漏OpenCV的VideoCapture没有正确release程序跑几个小时后内存占用从200MB涨到2GB。确保在finally块里调cap.release()。还有一个常见问题是OpenGL导致界面无显示前面提过设置软件渲染就行。如果你用的是远程桌面这个问题几乎必现。6. 常见问题速查与避坑经验汇总6.1 环境与依赖问题速查表问题现象可能原因解决方法torch.cuda.is_available()返回False显卡驱动版本过低更新驱动到515以上PyQt5界面空白OpenGL驱动不完整设置QT_OPENGLsoftware训练loss变NaNCUDA和PyTorch版本不匹配用CUDA 11.8 PyTorch 2.0.1pip安装PyQt5超时网络问题加国内镜像源界面元素模糊高DPI缩放未适配设置AA_EnableHighDpiScaling6.2 训练与推理常见异常处理训练时显存不足降低batch size或者用YOLOv8n而不是YOLOv8m。GTX 1660 Ti的6GB显存跑YOLOv8n batch16没问题跑YOLOv8m只能batch4。推理时检测框抖动视频相邻帧的检测结果不稳定可以加一个简单的跟踪算法比如IOU匹配做平滑或者对连续几帧的结果做投票。小坑洞漏检严重提高输入分辨率到1280或者在数据增强里加mosaic和scale。如果还不行考虑用YOLOv8的P2层特征图专门检测小目标。6.3 实操心得与独家技巧第一个心得数据集标注时坑洞的边界框不要贴太紧。我一开始标得很精确结果模型学到的边界特征很模糊换一段路就失效。后来把框放大5到10个像素泛化能力明显提升。第二个心得训练时用freeze参数冻结Backbone的前几层。如果你的数据集和COCO比较接近冻结前10层能加快收敛还能防止过拟合。命令是freeze10。第三个心得PyQt5界面设计时把耗时操作全部放子线程。不只是推理视频解码、结果保存、日志写入都应该异步。我见过有人把CSV写入放在主线程结果每写一行界面就卡一下。第四个心得模型文件不要放在中文路径下。Ultralytics在某些版本对中文路径支持不好会报编码错误。项目路径全用英文。第五个心得打包成exe时把模型文件和依赖库一起打包。PyInstaller的--add-data参数可以指定额外文件但要注意路径分隔符在Windows和Linux下不一样用os.path.join处理。6.4 后续扩展方向这个系统目前只做了检测后面可以加跟踪功能用ByteTrack或者DeepSORT给每个坑洞分配ID统计同一坑洞在视频中的持续帧数过滤掉误检。还可以加GPS定位把检测结果和经纬度绑定生成路面病害地图。如果要做毕业设计加一个Web端展示用Flask把检测结果推送到网页答辩时演示效果会好很多。我在实际部署中发现工地现场的光照条件比实验室恶劣得多模型在强逆光和夜间几乎不可用。后来加了一个图像预处理模块用CLAHE做自适应直方图均衡夜间检测召回率从0.3提到了0.6。这个改进成本很低但效果立竿见影建议你也试试。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进