路面坑洞检测这个题目,我在两年前接手过一个市政养护单位的小项目,当时他们的做法还是人工巡检车慢慢开、两个人盯着路面看,一天下来也就巡三四十公里,漏检率高得离谱。后来用YOLOv8 + Python + PyQt5搭了一套自动检测系统,巡检车装个普通工业相机,边跑边出结果,效率直接翻了几倍。这套东西说穿了不复杂:YOLOv8负责从图像里框出坑洞,PyQt5负责把检测结果做成一个能用鼠标点的桌面软件,Python 把两头串起来。目标是让刚学完深度学习入门课、懂点 Python 语法的人,也能照着把整套东西跑起来——从数据集标注一直到打包成一个双击就能打开的 exe。今天我把整个过程中的设计取舍、参数账、踩过的坑全部摊开讲一遍,尤其是那些教程里不会写、只有自己调过才知道的细节。
1. 路面坑洞检测这件事,先想清楚再动手
1.1 需求拆解:为什么不用传统图像处理
刚接触这个方向的人,第一反应往往是"坑洞不就是路面上一块深色的区域吗?用阈值分割、边缘检测不就完事了"。我一开始也这么想过,写了一段 OpenCV 代码,灰度化、高斯模糊、Canny 边缘、形态学闭运算,在几张晴天干燥的路面图上效果确实不错,能画出个大概轮廓。但换到实际巡检视频里就崩了:路面有阴影、有修补痕迹、有井盖、有积水反光、有车道线磨损,阈值一调就全军覆没。传统方法的根本问题是规则是死的,场景是活的,你没法用几行 if-else 穷举所有光照和材质组合。
目标检测的思路完全不同。它不关心"坑洞长什么样"这种人为总结的特征,而是让模型自己从成千上万张标注图里学。我给它看两万张坑洞,它自己会总结出"边缘不规则、内部偏暗、有深度感、和周围沥青有明显纹理差异"这些说不清但学得会的特征。这就是深度学习在这个任务上碾压传统方法的根本原因——特征工程从人转移到数据上。
具体到这个系统,需要满足的需求有这么几条:输入可以是单张图片、一段录制视频、或者摄像头实时流;输出是在原图上画框并标注置信度;界面上要能调置信度阈值和 IoU 阈值;还要能保存检测结果和统计数量。这四条看着简单,但每一条都会牵扯到后面代码结构的设计。
1.2 为什么选 YOLOv8 而不是两阶段检测器
检测算法分两大流派。一类是两阶段,代表是 Faster R-CNN 系列,先出候选框再分类回归,精度高但速度慢;一类是单阶段,代表就是 YOLO 系列,一次前向就出结果,速度快。路面巡检这个场景对速度是有硬要求的——巡检车按 40 公里时速跑,相机 30 帧每秒,你要是每帧处理要 200 毫秒,视频就会严重丢帧,很多坑洞根本没被看到。
YOLOv8 是 Ultralytics 在 2023 年推出的版本,相比 YOLOv5 有几个实打实的变化。第一是 Anchor-Free,取消了预设锚框,改成直接预测中心点到边界的距离。这一步的意义在于省掉了聚类锚框这个玄学环节,你换数据集不用再跑 k-means 调 anchor,对新手特别友好。第二是解耦头,分类分支和回归分支分开成两套卷积,两个任务的冲突变小了。第三是 TaskAlignedAssigner 正样本分配策略,把分类得分和定位精度联合起来决定哪个预测框负责哪个真值框,比纯 IoU 匹配合理。
实际对比我也做过:同一个坑洞数据集,YOLOv5s 跑出来 mAP50 大概 0.86,YOLOv8s 能到 0.89,推理速度在 GTX 1660 Ti 上还快了几毫秒。如果你要往边缘设备上放,yolov8n只有 320 万参数、8.7 GFLOPs,权重文件 6MB 出头,在 RK3588 这类的板子上也能跑到十几帧,这对车载部署是刚需。
1.3 整个系统分成四块,先画清楚边界
动手写代码之前,我把系统切成了四块,后面所有工作都往这四块里塞,避免写着写着结构乱掉:
| 模块 | 职责 | 关键依赖 |
|---|---|---|
| 数据模块 | 图片采集、标注、格式转换、划分 | labelImg、OpenCV、shutil |
| 训练模块 | 加载预训练权重、训练、验证、导出 | ultralytics、PyTorch |
| 推理模块 | 加载权重、单帧/批量推理、后处理 | ultralytics、OpenCV、NumPy |
| 界面模块 | 文件选择、参数调节、结果显示、导出 | PyQt5、QThread |
这样切的好处是每一块都可以单独测试。数据模块不依赖模型,你可以先用几张图跑通标注格式;推理模块不依赖界面,可以先用命令行脚本验证模型能不能出框;界面模块可以先用一张假图模拟推理结果来调试布局。哪一块出问题,一眼就能定位到,不用在几千行代码里大海捞针。
注意:很多人一开始就把训练和界面写在一个文件里,结果模型一加载界面就卡死,或者改个界面布局要重跑训练。职责分离这件事在原型阶段看着多余,到后期改需求的时候能救命。
2. 环境与数据:90% 的失败都埋在这一步
2.1 环境配置:版本锁死比装最新版重要
深度学习项目最容易翻车的地方不是模型,是环境。我的建议就一条:不要装最新版,装互相兼容的版本。下面这套组合我在三台机器上都跑通过,直接抄就行。
先说显卡驱动和 CUDA。用nvidia-smi看右上角那个 CUDA Version,它代表驱动最高能支持的 CUDA 版本,不是你必须装的版本。比如显示 12.2,那你装 CUDA 11.8 或 12.1 都没问题。PyTorch 官方给的对应关系一定要看,torch 2.x对应cu118或cu121,装错了就会出现torch.cuda.is_available()返回 False 的经典问题。
# 建议用 conda 建独立环境,Python 版本选 3.9 或 3.10 conda create -n pothole python=3.9 -y conda activate pothole # 安装 PyTorch(CUDA 11.8 版本,注意 --index-url 别写错) pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cu118 # 验证 python -c "import torch; print(torch.__version__, torch.cuda.is_available())"然后是 ultralytics 和 PyQt5:
pip install ultralytics==8.0.196 opencv-python==4.8.1.78 pip install PyQt5==5.15.9PyQt5 的安装时长一直是新手抱怨的点,pip install PyQt5有时候要等好几分钟,因为它在下载几十兆的 wheel 包。如果卡住不动,换个国内镜像源就快了:
pip install PyQt5==5.15.9 -i https://pypi.tuna.tsinghua.edu.cn/simple还有一个坑:opencv-python和opencv-contrib-python不能同时装,后装的会把前者的部分文件覆盖掉,导致cv2导入时报 DLL 加载失败。如果你不确定装过哪个,先pip list | grep opencv看一眼,有多个就全部卸掉重装一个。
提示:环境装完立刻跑一次
yolo detect predict model=yolov8n.pt source=bus.jpg,能出图说明整条链路是通的。这一步花两分钟,能省掉后面两小时的排查。
2.2 数据集采集与标注:标注质量决定上限
模型的天花板是数据决定的,这句话在坑洞检测上体现得特别明显。我一开始图省事,从网上凑了几千张,结果模型在阴天和湿滑路面上的表现惨不忍睹,后来补采了雨天、黄昏、逆光三类场景各一千多张,mAP 直接涨了 6 个点。
采集渠道有几个:装个手机支架在车上录视频再抽帧,这是最省事的;公开数据集可以用 RDD2022 这类道路病害数据集,里面按国家分了子集,病害分成纵向裂缝、横向裂缝、龟裂和坑洞几类,把坑洞那部分捞出来能用;实在不够就自己骑车绕城拍,重点是多拍不同光照和不同路面材质。
标注用 labelImg 就够了,选 YOLO 格式,输出的是每行类别id 中心x 中心y 宽 高的归一化文本。坑洞标注有几个经验点必须说:
第一,边界模糊的坑洞要往外留 2 到 3 个像素。坑洞边缘是渐变的,从完好沥青到破损有个过渡带,你要是贴着最深处标,模型的回归目标就会偏小,检出的框会明显缩水。
第二,积水覆盖的坑洞照样标。积水是坑洞的伴随现象,不标的话模型会学到"有水的不是坑洞"这个错误关联,实际巡检里雨天漏检率会飙升。
第三,小于 10×10 像素的忽略掉。这种目标在 640 分辨率下连一个特征点都不到,标了只会给模型添噪声,而且实际养护也用不着修这么小的坑。
第四,子类别别乱加。有人想把坑洞分成"轻微、中等、严重"三类,除非你有明确的判定标准和足够的样本量,否则别做。类别一多,每类的样本量就被稀释,模型反而学不好。先从单一类别pothole跑通再说。
标注完之后,我习惯跑一个脚本做一遍体检,统计每个类别的框数量、框的宽高分布、有没有宽高为 0 的脏数据。这一步能提前发现标注时手滑画出来的异常框。
2.3 YAML 配置与目录结构:格式错一个字母就白跑
YOLOv8 的数据集目录结构是固定的,别自己发挥:
datasets/pothole/ ├── images/ │ ├── train/ (约 70%) │ ├── val/ (约 20%) │ └── test/ (约 10%) ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ └── pothole.yamlimages和labels下的文件名必须一一对应,只是后缀不同(.jpg对.txt)。新手最常见的错误是把 labels 放到了 images 里面,或者文件夹名字写成image,模型找不到标签就会把所有图当成背景,训练出来的模型一个框都不出。
pothole.yaml长这样:
path: /home/user/datasets/pothole train: images/train val: images/val test: images/test names: 0: pothole注意path建议写绝对路径,相对路径在切换工作目录的时候会失效。names的键必须从 0 开始连续,写成1: pothole会报索引错误。如果你从别的格式(比如 COCO 的 json、labelme 的 xml)转过来,names的顺序一定要和标注文件里的类别 id 对上,对不上就是全错,模型会认认真真地把坑洞学成"背景"。
2.4 数据增强与划分策略:别让验证集骗了你
YOLOv8 训练时默认开了 Mosaic、随机缩放、随机翻转、HSV 色彩抖动这一套增强。Mosaic 是把四张图拼成一张,等于一个 batch 里塞进了四种场景,对小数据集特别有效。但有两个开关需要你手动想清楚:
fliplr水平翻转默认 0.5,对坑洞没问题,路面左右翻转后语义不变。但flipud垂直翻转默认是 0,千万别打开——颠倒了的路面在现实中不存在,模型会学到错误的上下文。
mosaic在最后 10 个 epoch 建议关掉(用close_mosaic=10)。因为 Mosaic 拼出来的图边缘会有拼接痕迹,而且目标的尺度分布和真实图片不一样,训练末期还开着会让最终模型对正常图片的适应性变差。我自己试过,同样的数据,加不加close_mosaic=10,验证集 mAP 差 1.5 个点左右。
数据划分上,千万不要随机按 8:2 分完就完事。如果同一段路的连续抽帧被随机分到训练集和验证集,那验证集里就会出现和训练集几乎一模一样的图,指标虚高得厉害,你自己骗自己。正确的做法是按拍摄路段或按时间段划分:比如 A 路段的所有图进训练集,B 路段的进验证集,C 路段的进测试集。这样验证集才是真正的"没见过的数据",指标才有参考意义。我当时第一次跑就是随机分的,mAP 0.94 高兴了半天,换成按路段分之后掉到 0.87,这才是真实水平。
3. 训练:参数背后的账要算清楚
3.1 从预训练权重出发,别从零开始
权重选择上,除非你的数据集超过十万张,否则永远从预训练权重开始。COCO 上训出来的yolov8n.pt或yolov8s.pt已经学到了通用边缘、纹理、形状特征,你只需在它的基础上微调。从零训和从预训练训的差距有多大?我在同一个数据集上对比过,从零训到 mAP50 0.85 需要大概 300 个 epoch,从预训练开始 80 个 epoch 就到了,时间差了将近四倍。
模型尺寸怎么选:n最快最省显存,适合边缘部署;s精度和速度平衡得最好,我大部分项目都用它;m及以上在单张图几十个坑洞这种场景里提升不明显,但显存直接翻倍。GTX 1660 Ti 这种 6GB 显存的卡,跑yolov8s+imgsz=640+batch=8是稳的,batch=16会在训练中途 OOM。
训练命令就一行:
yolo detect train \ model=yolov8s.pt \ data=datasets/pothole/pothole.yaml \ epochs=100 \ imgsz=640 \ batch=8 \ device=0 \ workers=4 \ project=runs/pothole \ name=v8s_640 \ close_mosaic=10 \ patience=303.2 关键超参数:每个数字都有账
学习率 lr0。默认 0.01,这个值是给 SGD 优化器配的。如果你改用 AdamW(optimizer=AdamW),必须降到 0.001,否则损失会剧烈震荡甚至发散。还有一个线性缩放规则值得记一下:lr = 0.01 × batch_size / 64。你 batch 用 8,理论学习率就是 0.00125。当然这是经验公式,微调时我通常取比它略大一点点的值,比如 0.002,收敛更快。
batch size。不是越大越好。小 batch 的梯度噪声大,反而有正则化效果,小数据集上用 8 或 16 往往比 64 泛化更好。而且 batch 大了,学习率也得跟着调,否则等于变相减小了有效步长。显存不够就用batch=-1让 ultralytics 自动按显存占用 60% 来推一个值,这个功能挺好用。
imgsz。默认 640,输入会被缩放到 640×640 并且做 letterbox 填充。这个值直接影响小目标检出率。坑洞如果普遍偏小(比如占原图不到 3%),把 imgsz 提到 960 或 1280 效果会明显改善,代价是显存和耗时按平方增长——640 到 1280,计算量涨四倍。另一种省资源的做法是保持 640 训练,推理时用imgsz=1280,通常也能捡回一部分小目标,但要注意训练和推理尺度差太多会掉点,最好在验证集上比一比。
freeze。冻结主干网络的前 N 层,通常在你数据集很小(少于两千张)的时候用,比如freeze=10冻结前 10 层。好处是防止小数据把预训练特征冲垮,坏处是你的数据分布如果和 COCO 差很远,冻结太多会导致学不动。我的经验分界线是:五千张以上不冻结,两千张以下冻结前 10 层,中间自己试。
warmup 和余弦退火。warmup_epochs=3让学习率从很小的值慢慢升到设定值,避免一开始就把预训练权重破坏掉。cos_lr=True打开余弦退火,学习率按余弦曲线衰减,末期小学习率能让模型更稳地落进局部最优。这两个基本属于免费涨点,没什么理由不开。
3.3 损失曲线怎么读:三种异常形态
训练跑起来之后,runs/pothole/v8s_640/下面会生成results.csv和一堆曲线图。判断训练健不健康,看三条线就够了。
box_loss 和 cls_loss 同步下降,说明分类和定位都在学,这是正常状态。如果 box_loss 一直在降而 cls_loss 平着不动,多半是类别标注有问题,比如 id 对不上或者全标成了同一个类。
验证损失开始上升但训练损失继续下降,这是过拟合的典型信号,说明模型开始背训练集了。对策有几个:加数据、开更强的增强(比如把mixup从 0 调到 0.15)、加weight_decay、或者干脆早点停。patience=30就是干这个的,验证指标 30 个 epoch 不涨就自动停。
损失出现 NaN 或者突然炸到几百,八成是学习率太大或者数据里有脏标签。先查标注文件有没有坐标超过 1.0 的值,再降学习率。我在一次训练里遇到过坐标写成1.02的情况,模型的两个 epoch 后 loss 直接变 NaN,排查了半天才想起来查标注。
想看曲线图的话,ultralytics 自带的results.png里把三条 loss 和三个指标画在一起了,也可以用 pandas 读results.csv自己画,那样能放大看细节。我习惯在训练结束后单独画一张 mAP50 和 mAP50-95 的对比图,能直观看出模型在宽松阈值和严格阈值下的差距有多大。
3.4 指标解读与阈值调优:mAP 高不等于好用
训练完跑验证:
yolo detect val model=runs/pothole/v8s_640/weights/best.pt data=datasets/pothole/pothole.yaml输出里几个指标要分清。Precision是你报出来的框里有多少是真的,Recall是真的框里你找出来多少,mAP50是 IoU 阈值 0.5 时的平均精度,mAP50-95是把 IoU 从 0.5 到 0.95 每隔 0.05 都算一遍再平均。工程上我更关心 Recall,因为漏检一个坑洞的代价是路面继续恶化甚至爆胎,而误检一个最多是养护工白跑一趟。
验证时那个conf阈值非常关键。默认是 0.001(为了画 PR 曲线用的),但实际部署时你要选一个工作点。我一般会画一条 Precision-Recall 曲线,找 F1 最大的那个点。实测下来,坑洞检测在conf=0.25、iou=0.45附近比较均衡,能拿到 0.88 左右的 F1。如果场景更看重不漏检,把 conf 降到 0.15,Recall 能到 0.95,代价是 Precision 掉到 0.8 左右,多出来的误检人工筛一下就行。
还有个细节:NMS 的 iou 阈值。相邻的两个坑洞如果挨得近,iou 设太大会被 NMS 合并成一个,设太小又会出现同一个坑洞被框两次。iou=0.45是个比较稳的起点,密集坑洞场景可以调到 0.5 到 0.6。
4. PyQt5 界面:把模型变成能交付的软件
4.1 界面布局:左侧参数、中间画布、底部状态
界面设计上我坚持一个原则:常用操作一屏内可触达,不用翻菜单。最终布局是经典的左中右结构。
左侧从上到下依次是:文件选择区(图片、视频、摄像头三个按钮)、参数区(置信度滑块、IoU 滑块、显示标签复选框)、控制区(开始、暂停、停止)。中间是主显示区,用QLabel承载视频帧。右侧可以放检测结果列表,把每一帧检测到的坑洞数量和平均置信度打出来。底部是一条状态栏,显示当前帧号、FPS 和处理耗时。
参数区用QSlider加QDoubleSpinBox联动,滑块拖动时数字跟着变,数字改了滑块也跳,这样既能快速拖又能精确定值。信号是valueChanged,绑到一个更新内部变量的槽函数上,别在推理循环里直接读控件值,那样会反复触发 UI 事件。
有一点值得说:分辨率适配。现在很多笔记本是 2K、4K 屏,默认 DPI 缩放下 Qt 控件会变得极小或者模糊。解决办法是在QApplication创建之前设置属性:
from PyQt5.QtCore import Qt QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)另外如果你在不同分辨率的机器间分发,布局尽量用QVBoxLayout、QHBoxLayout这类相对布局,少用setGeometry写死坐标,否则换个屏幕就错位。
4.2 线程分离:界面卡死是最大的体验杀手
这是整个界面开发里最重要的一条:推理必须放在子线程。如果你在主线程里写while True: ret, frame = cap.read(); results = model(frame),界面会直接无响应,因为 Qt 的事件循环被你的死循环堵住了,窗口连重绘都做不了,标题栏会显示"未响应"。
正确做法是继承QThread,把推理循环放到run()里,结果通过pyqtSignal发回主线程:
class DetectThread(QThread): frame_ready = pyqtSignal(np.ndarray, list) stats_ready = pyqtSignal(int, float) def __init__(self, model, source, conf, iou): super().__init__() self.model = model self.source = source self.conf = conf self.iou = iou self._running = True def run(self): cap = cv2.VideoCapture(self.source) while self._running and cap.isOpened(): ret, frame = cap.read() if not ret: break t0 = time.time() results = self.model.predict(frame, conf=self.conf, iou=self.iou, verbose=False) boxes = results[0].boxes annotated = results[0].plot() self.frame_ready.emit(annotated, boxes.data.tolist()) self.stats_ready.emit(len(boxes), 1.0 / (time.time() - t0 + 1e-6)) cap.release() def stop(self): self._running = False这里有个必须注意的点:信号里传的 numpy 数组是引用,主线程处理的时候如果子线程已经把这块内存改了或者释放了,你会看到花屏或者程序崩溃。稳妥做法是在 emit 之前frame.copy()一下,代价是一次内存拷贝,几毫秒而已,但能避免那种偶发的、极难复现的崩溃。
还有一个容易忽略的地方:参数更新。滑块改了 conf,子线程怎么知道?直接读控件会跨线程访问 UI 对象,不安全。正确的做法是定义一个update_params方法,在主线程调用它更新线程内的成员变量。因为 Python 的赋值是原子的,这种简单变量的跨线程读写不会出问题。
4.3 图像转换:从 BGR 到 QImage 的那几行代码
OpenCV 读进来的是 BGR 顺序的 numpy 数组,Qt 要的是 RGB 的QImage,中间转换写错是新手最常见的"能跑但颜色发蓝"的原因:
def cvimg_to_qpixmap(frame, target_w, target_h): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape bytes_per_line = ch * w qimg = QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) # 必须 copy(),否则 rgb 被回收后 QImage 会指向野内存 pixmap = QPixmap.fromImage(qimg.copy()) return pixmap.scaled(target_w, target_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)QImage的构造函数不拷贝数据,它只是引用你传进去的那块内存。函数返回后rgb被垃圾回收,qimg就变成了野指针,表现是画面时好时坏、偶尔花屏。加上.copy()就彻底解决了。
缩放用KeepAspectRatio保持宽高比,不要拉伸,否则检测框会变形,看着别扭。SmoothTransformation比FastTransformation慢一点但画质好很多,视频流里如果帧率不够可以换成快速模式。
还有一个界面类问题是QOpenGLWidget在某些机器上显示空白。这个不是代码 bug,是 Qt 的 OpenGL 渲染后端和显卡驱动没配合好。临时验证的办法是在程序启动最前面加一句os.environ["QT_OPENGL"] = "software",强制走软件渲染,画面就能出来了。长期方案是更新显卡驱动,或者在main函数里用QSurfaceFormat显式设定版本和 profile。我遇到过两次,都是这么解决的。
4.4 打包成 exe:让不懂 Python 的人也能用
交付的时候对方不会装 Python 环境,得打包。PyInstaller 是主流选择,但 ultralytics 和 PyQt5 这两个库打包起来坑不少。
pyinstaller -D -w main.py \ --name PotholeDetector \ --add-data "runs/pothole/v8s_640/weights/best.pt;weights" \ --hidden-import ultralytics \ --hidden-import ultralytics.nn.tasks \ --collect-all ultralytics \ --collect-all PyQt5 \ --exclude-module matplotlib \ --exclude-module torchvision几个关键点。第一,模型权重文件要随包带上,--add-data的原生写法在 Linux 和 Windows 上分隔符不一样,Windows 用分号,Linux 用冒号。第二,ultralytics有动态导入的模块,PyInstaller 静态分析扫不到,必须用--hidden-import或者--collect-all手动带上。第三,代码里读文件路径要区分打包前后:
import sys, os def resource_path(rel): base = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base, rel)sys._MEIPASS是 PyInstaller 解压临时目录的路径,打包后才有这个属性。用这个函数读权重文件,开发时和打包后都能找到。
打包出来的目录动辄一两 G,主要是 PyTorch 贡献的。想瘦身可以用 CPU 版的 PyTorch(少掉几百兆的 CUDA 库),代价是推理慢十倍左右,看你的部署场景选。
5. 常见问题速查:我踩过的坑
5.1 环境类问题速查表
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
torch.cuda.is_available()返回 False | 装成 CPU 版 torch,或 CUDA 版本不匹配 | 用官方 index-url 重装对应 cu 版本 |
ImportError: DLL load failed | opencv 装重复了 | 卸载所有 opencv 包,只装一个 |
训练报CUDA out of memory | batch 或 imgsz 太大 | 降 batch,或batch=-1自动推 |
No labels found | 目录结构不对或 yaml 路径错 | 检查 images/labels 是否同级对应 |
| PyQt5 安装卡住不动 | 源太慢 | 换清华或阿里镜像源 |
| 运行 yolo 命令提示找不到 | 环境没激活或 script 目录不在 PATH | 用python -m ultralytics替代 |
5.2 训练类问题:几个反直觉的现象
训练一开始 loss 就是 0。别高兴,这通常是标注全丢了,模型把所有东西都当背景,损失自然是 0。检查 labels 目录是不是空的,或者 txt 文件里的内容是不是被某种工具清掉了。
mAP 一直卡在 0.5 上不去。先看验证集和训练集的分布是不是差太远(比如训练全是晴天,验证全是雨天),再看 imgsz 是不是太小导致小坑洞检不出来,最后看标注框是不是普遍偏大或偏小。测框大小有个简单办法:算所有标注框的面积占整图面积的比例,坑洞这个任务通常集中在 1% 到 8% 之间,如果你算出来平均只有 0.3%,那说明目标太小,得提分辨率。
训练速度忽快忽慢。多半是workers设太多,CPU 数据加载和 GPU 抢资源。workers一般设成 CPU 核心数的 60% 到 80%,8 核机器设 4 到 6 就行。还有可能是磁盘 IO 慢,把数据集放到 SSD 上会明显改善。
5.3 界面与显示类问题
画面颜色发蓝发红,一定是 BGR 和 RGB 搞反了,看 4.3 节那段转换代码。
点了开始按钮界面就假死,推理没放子线程,或者放了子线程但信号连接用了DirectConnection,等于还是在主线程执行。
视频播到一半卡住不动,cap.read()返回了 False 但循环没退出。视频读到末尾会返回 False,要在循环里判断并跳出,或者做循环播放处理。
检测框抖动厉害,是逐帧独立检测导致的,相邻帧结果没有关联。可以做一次简单的框平滑,比如对连续 3 帧的框做加权平均,视觉上会稳很多。这属于锦上添花,不影响功能。
5.4 检测效果不理想,按这个顺序排查
第一步永远是拿训练集里的图去推理。如果训练集上都不准,那是训练本身有问题;如果训练集准而新图上不准,那是泛化问题,得补数据。这个二分法能帮你省掉大量瞎调参的时间。
第二步按场景分类统计漏检。把测试集按白天/夜间、干燥/积水、近距离/远距离分几组,分别算召回率,看短板在哪一组。我那次项目里,夜间和远距离两组的召回率只有 0.6,其他组都在 0.9 以上,定位非常清晰,之后就针对性地补了这两类数据。
第三步看漏检框和真值框的 IoU 分布。如果漏检的框其实 IoU 在 0.3 到 0.5 之间,说明模型其实找到了,只是定位不够准,可以放宽 NMS 或者调低 conf。如果 IoU 都在 0.1 以下,那就是真没找到,得从数据和分辨率上想办法。
6. 实测经验与后续扩展
6.1 几条个人心得
数据比模型重要,模型比调参重要。这句话我反复验证过。同样一套代码,数据从 3000 张扩到 12000 张,覆盖了更多场景,mAP 能涨十几个点;换个更大的模型,涨两三个点;调学习率和增强参数,再榨出一两个点。时间该花在哪,一目了然。
先跑通再优化。我见过不少人一开始就纠结用不用注意力机制、要不要换更复杂的结构,结果两周过去连 baseline 都没跑出来。先用yolov8n+ 默认参数跑一遍,拿到一个能出框的模型,之后再逐步替换升级,每一步都有对比,才知道改动是真有效还是自我感觉良好。
测试集要留到最后用。调参的时候看验证集就够了,测试集每动一次就污染一次,看完指标你就不自觉地往那个方向调了。把测试集捂到交付前最后看一眼,那个数字才是真实水平。
推理速度的瓶颈往往不在模型。我做过一次耗时拆解,yolov8s在 640 分辨率下单帧前向大概 12 毫秒,但画框、转 QImage、缩放到界面尺寸、刷新控件加起来要 20 多毫秒。想要整体流畅,这些"周边"代码也得优化,比如复用 QPixmap 对象、减少不必要的.copy()、把缩放交给 Qt 的硬件加速来做。
6.2 这套东西还能往哪扩展
最直接的扩展是导出成 ONNX 或 TensorRT 加速。用yolo export model=best.pt format=onnx opset=12 simplify=True导出的 ONNX 模型,在 ONNX Runtime 下推理比 PyTorch 原生快 1.5 到 2 倍;再往上走 TensorRT,在 NVIDIA 的板子上能快 3 倍以上。部署到嵌入式平台比如 RK3588 这类芯片上时,官方一般会提供模型转换工具链,流程是把 PyTorch 权重转 ONNX 再转成芯片专用格式,中间要注意算子兼容性,遇到不支持的算子要么换版本要么自己写插件。
功能上可以加坑洞面积估算和分级。把检测框的像素尺寸结合相机标定参数换算成实际面积,超过某个阈值就标红预警。这需要标定相机内参和安装高度,公式是实际尺寸 = 像素尺寸 × 物距 / 焦距,简单但好用。再往前走一步,把多帧检测结果和 GPS 位置关联起来,就能生成一张路面病害分布图,交给养护部门排工期,这个东西的实用价值比单帧检测高得多。
再远一点的方向是时序跟踪。给每个坑洞分配一个 ID,跟踪它在连续帧里的位置变化,这样同一辆车跑一遍就能算出每个坑洞被拍到几次、平均置信度多少,比单帧判断可靠得多。实现上可以用 ByteTrack 这类轻量跟踪器接在检测后面,代码量不大,效果提升明显。
我现在还在用的一个组合是:YOLOv8s 做检测 + ByteTrack 做跟踪 + PyQt5 做界面,巡检车时跑 40 公里出头,单帧端到端耗时 35 毫秒左右,一天能覆盖一百多公里路面。这套东西没有什么高深的技术,关键在于每个环节都老老实实地把数据、参数和边界情况处理干净了,剩下的就是耐心。