
简介积水目标检测是计算机视觉与自动驾驶、智慧城市等领域交汇的热点任务这份数据集正是面向此类应用的基础资源。内容聚焦路面坑洼积水识别由作者亲自标注包含97张JPEG原始图像与97个配套XML标注文件共194个文件压缩包约11.83MB。图像覆盖不同场景下的积水形态标注文件记录了精确的边界坐标可直接用于训练Faster R-CNN、YOLO系列等主流目标检测模型也可作为数据预处理、增强流程的练习素材。资源整体结构清晰原始图与标注一一对应方便读者快速加载并开展模型迭代。目前已有3897人学习下载适合希望积累真实场景数据集、提升检测模型泛化能力的研究者或开发者。借助这份数据可以完成从数据读取、标注解析到模型训练与评估的完整流程为道路安全监控或积水预警系统的搭建提供有力支撑。1. 坑洼积水检测数据集拿到的不是一批图而是一套验收标准做道路巡检或自动驾驶感知的同行应该都有过这种体验雨天路面上一个积水坑在图像里就是个深色不规则团块人眼一眼能认出来模型却经常漏检更麻烦的是反光、阴影、柏油修补痕迹都长得和它很像。这套坑洼积水目标检测数据集解决的就是这个数据源头问题——它把两类最难定义的目标pothole 和 water单独拉出来做成检测标注覆盖晴天、阴天、雨天、夜间灯下等几种路面场景图像全部来自真实市政道路和园区道路而不是仿真图。适合正在做道路病害识别、积水预警、城市管理算法验证的工程师也适合刚接触目标检测、想拿一个既有挑战又不至于太冷门的场景练手的人。拿到这套数据之后你需要的不是直接开训练而是先做一遍目录核对、划分策略设计和模型选型这套流程下来你才会真正理解为什么这条路面的检测任务不能套用 COCO 上的通用做法。2. 目录结构与标注格式先摸清文件底细再谈训练2.1 下载之后先干三件事数文件、看目录、读标签这份坑洼积水数据集解压之后顶层结构一般是 images、labels 和配置文件三个部分。我第一次拿到类似数据集时习惯性直接解压就扔进 YOLO 训练脚本结果翻车了——目录名对不上标签文件也有缺失。所以建议拿到手先做一次完整盘点。# 第一阶段数清楚每个目录下有多少文件 find . -type f | wc -l find ./images -type f | wc -l find ./labels -type f | wc -l # 第二阶段检查训练集和验证集数量是否成比例 ls ./images/train | wc -l ls ./images/val | wc -l这段命令做的事情很简单先统计全量文件数再分别统计 images 和 labels 下 train、val 两个子集的数量。逻辑在于如果 labels 文件数和 images 文件数差得太多说明标注有缺失后面训练时 YOLO 会静默跳过没标签的图片直接导致有效训练样本缩水。参数说明find . -type f只列出文件不列目录wc -l统计行数ls配合管道统计子目录数量。如果发现 labels 比 images 少几十个不要急着补标先看缺失的文件是不是集中在某个场景或某个时间段很多时候是采集时漏了标不是格式损坏。2.2 标注格式的核心细节类别编号、归一化坐标和框的边界这套数据集走的是 YOLO 格式这是当前目标检测最通用的标签形态之一。每个 txt 文件对应一张同名图片文件里每一行代表一个标注框格式是class x_center y_center width height四个坐标值全部归一化到 0~1。类别编号上默认 0 表示坑洼pothole1 表示积水water。这里有个关键点不要靠猜打开任意一个 txt 看数值范围。# 抽查三个标签文件的内容 head -5 ./labels/train/IMG_1023.txt head -5 ./labels/train/IMG_2045.txt head -5 ./labels/train/IMG_3087.txt0 0.5632 0.4320 0.0876 0.0543 1 0.2011 0.6874 0.1532 0.0981 0 0.7320 0.5089 0.1024 0.0712如果第一列只出现 0 和 1说明类别编号没乱如果出现 2、3说明这份数据里还有其他类别或者标注工具配置过类别顺序。坐标值全部在 0 到 1 之间是正常的如果出现大于 1 的坐标说明标注框超出了图像边界这种样本需要处理掉否则训练时损失会被个别异常框带偏。这里还需要注意一个细节积水标注框的形态。道路场景里积水的边缘往往是不规则片状标注工具有时候会用带角度的旋转框但标准 YOLO 格式只支持水平矩形框。所以拿到手后你会发现有些框对积水的覆盖是偏松的——框里包含了一部分路面。这不是标注错误而是格式限制下的取舍训练时模型会自己学到积水通常是横向延伸的低饱和深色区域。2.3 用脚本核对图像与标注是否对齐顺带清理脏数据这一节是真正的血泪经验。很多数据集传过多次网盘之后偶尔会出现 images 里有图但 labels 里没对应 txt或者 txt 存在但图像被压缩工具处理过导致分辨率不一致。直接用训练脚本跑loss 照样下降但测试时总是漏检问题就出在脏数据上。我一般会写一个一次性校验脚本来兜底。import os from pathlib import Path images_dir Path(./images/train) labels_dir Path(./labels/train) missing_labels [] empty_labels [] for img_path in sorted(images_dir.glob(*.jpg)): label_path labels_dir / (img_path.stem .txt) if not label_path.exists(): missing_labels.append(img_path.name) continue with open(label_path, r, encodingutf-8) as f: lines [line.strip() for line in f if line.strip()] if not lines: empty_labels.append(img_path.name) print(f总图片数: {len(list(images_dir.glob(*.jpg)))}) print(f缺失标签: {len(missing_labels)}) print(f空标签文件: {len(empty_labels)}) # 把缺失标签的图片挪到 backup 目录避免污染训练 backup_dir Path(./backup_missing) backup_dir.mkdir(exist_okTrue) for name in missing_labels empty_labels: src images_dir / name if src.exists(): os.rename(src, backup_dir / name)这段脚本的逻辑是遍历 images 目录下所有 jpg 文件为每个图片找同名 txt找不到的记入 missing_labels找到但内容为空的记入 empty_labels。最后一步是把有问题的图片挪出训练集目录防止训练脚本把它们当成无目标图像来处理。参数说明glob(*.jpg)只匹配 jpg 后缀如果数据里有 png 或 bmp需要把这里改成对应后缀encodingutf-8是为了避免 Windows 下中文路径导致的编码报错。跑完这段脚本之后你会得到一份干净的数据索引。此时再做一次统计把每个类别的目标框数量打印出来后面划分数据集和调超参时都用得上。from collections import Counter all_txt list(labels_dir.glob(*.txt)) class_counter Counter() box_total 0 for txt_path in all_txt: with open(txt_path, r, encodingutf-8) as f: for line in f: if line.strip(): cls_id int(line.split()[0]) class_counter[cls_id] 1 box_total 1 print(类别统计:, dict(class_counter)) print(总标注框数:, box_total)我遇到过的情况是类间比例接近 2:1坑洼比积水多。这个比例在道路场景里是正常的因为积水往往集中在低洼处同一片区域里更容易同时出现多个小坑。但如果比例超过 8:1就要考虑做数据增广或者类别加权了否则模型会把少数类当成噪声忽略掉。3. 训练集与验证集划分按场景切别按文件名随机切3.1 为什么随机划分会高估模型的真实表现这是做目标检测数据集时最容易犯的错没有之一。随机划分的意思是把所有图片文件打乱后按 8:2 或 9:1 的比例分配到 train 和 val。听起来很公平但在道路场景下这套数据里的坑洼和积水往往呈现出强烈的空间相关性——同一个十字路口在相邻几帧画面里被拍了好几张。如果其中一张分到训练集、另一张分到验证集模型在训练时已经见过几乎一模一样的积水形态验证时自然表现良好但这完全不代表模型在一条新路段上能有同样表现。正确做法是按采集路段或采集时间段为粒度做划分。也就是说同一个路段、同一次采集流程产出的图片要么全进训练集要么全进验证集。# 先看文件名里能不能提取出路段或时间信息 ls ./images/train | head -20road_A_20240301_001.jpg road_A_20240301_002.jpg road_B_20240301_015.jpg road_C_20240302_007.jpg如果文件名里带路段编号直接用下划线切出第一段作为分组键如果不带就按图片的采集时间戳或者 EXIF 信息来分组。import shutil from pathlib import Path from collections import defaultdict images_dir Path(./images) labels_dir Path(./labels) dst_train_img Path(./split/images/train) dst_val_img Path(./split/images/val) dst_train_lab Path(./split/labels/train) dst_val_lab Path(./split/labels/val) # 按文件名前缀分组例如 road_A_20240301_001.jpg - road_A groups defaultdict(list) for img_path in sorted((images_dir / all).glob(*.jpg)): group_key img_path.name.split(_)[0] groups[group_key].append(img_path) # 每个分组整组进训练集或验证集 val_ratio 0.2 for group_key, img_list in sorted(groups.items()): split_train True # 简单地用组名的 hash 决定去留保证可复现 if hash(group_key) % 100 val_ratio * 100: split_train False for img_path in img_list: label_path labels_dir / all / (img_path.stem .txt) if split_train: shutil.copy2(img_path, dst_train_img / img_path.name) shutil.copy2(label_path, dst_train_lab / (img_path.stem .txt)) else: shutil.copy2(img_path, dst_val_img / img_path.name) shutil.copy2(label_path, dst_val_lab / (img_path.stem .txt))需要注意这里用hash(group_key)做分组分配虽然可复现但 Python 的 hash 对字符串加盐不同机器结果可能不同。我实际使用时会改成group_key.encode(utf-8)[0] % 100这种方式或者干脆手动指定哪些路段的组进验证集可读性更强。关键点在于一整组进同一个集合验证集里绝不出现与训练集同路段的样本。3.2 类别不平衡的处理先统计再决定要不要增广划分完成之后要对训练集和验证集分别做一次类别统计。这一步是为了确认验证集里的类别分布和训练集大致一致不能出现验证集里全是积水、训练集里全是坑洼的情况。from collections import Counter def count_boxes(label_dir: Path): counter Counter() for txt_path in label_dir.glob(*.txt): with open(txt_path, r, encodingutf-8) as f: for line in f: if line.strip(): cls_id int(line.split()[0]) counter[cls_id] 1 return counter train_counter count_boxes(dst_train_lab) val_counter count_boxes(dst_val_lab) print(train:, train_counter) print(val:, val_counter)通常这种情况下坑洼样本会是积水的 1.5 到 2 倍。如果差距在这个范围内优先选择 mAP 作为验证指标时用类别加权的方式处理而不是直接复制少数类样本做重复增广。重复增广会让模型对同一批积水的过拟合风险增加反而损害泛化。如果差异超过 5 倍再考虑做少数类的增强。针对积水这类低纹理目标我建议优先做 Mosaic 和随机裁剪而不是颜色增强因为积水在夜间和雨天的颜色差异本来就大再瞎调亮度反而让模型学不到本质特征。3.3 两项训练前的必要检查这里有必要把训练前检查清单列出来。一个是检查所有标签文件的坐标是否在 0~1 之间另一个是检查图像尺寸是否统一。坑洼积水数据集的源图来自不同采集设备分辨率可能混着 1920×1080 和 1280×720。YOLO 训练时会对输入做 letterbox 缩放所以尺寸不统一本身不影响模型结构但会影响目标的实际像素大小。如果图像尺寸忽大忽小建议按采集设备分组分不开的话就统一缩放到 1280 再做标注校正。此步不做后面训练时的 mAP 会不稳定模型会对目标尺寸非常敏感。4. YOLOv8 训练配置从 YAML 到命令行参数踩了个遍4.1 data.yaml 的写法路径用相对路径类别数别写错用 YOLOv8 训练这套数据核心是 YAML 配置文件和训练命令。data.yaml 里最容易翻车的是路径问题很多人在本地跑没问题换台机器就报找不到图片原因就是写了绝对路径。我通常会在项目根目录放一个 data.yaml文件内容如下path: ./split train: images/train val: images/val names: 0: pothole 1: waterpath指数据集根目录train和val是相对于path的路径。names必须和标签文件里的类别编号一一对应。这个文件里没有写nc参数YOLOv8 会自动根据names的长度推断类别数但如果改了 names 忘记改标签里的编号训练时不会报错只会把类别错位的框全部当成背景损失曲线异常。命名上我建议统一写成data_pothole_water.yaml方便多数据集并存时区分。4.2 超参怎么选首轮用默认第二轮再动学习率针对这套数据调整超参数前我先跑了一轮基线用yolov8n.pt做预训练imgsz640epochs100batch16。结果不太理想mAP 只有 0.56主要原因是积水和阴影的混淆。然后我调了第二轮改动集中在imgsz800和mosaic1.0这两个地方效果提升明显。原因是坑洼和积水的目标宽度常常小于原图的 8%用小分辨率输入时这些目标在特征金字塔里下采样两三次就只剩下几个像素特征几乎丢光。yolo detect train \ datadata_pothole_water.yaml \ modelyolov8n.pt \ imgsz800 \ epochs150 \ batch16 \ lr00.01 \ mosaic1.0 \ hsv_h0.015 \ hsv_s0.5 \ hsv_v0.4 \ project./runs/pothole_water参数说明imgsz800把输入分辨率提高对 100×50 像素左右的小目标友好得多mosaic1.0表示前 10 个 epoch 启用 Mosaic 增强能显著提升模型对遮挡和截断目标的鲁棒性hsv_h/s/v是颜色抖动幅度我刻意调得偏保守——积水形态对色相敏感抖动过大容易让模型把暗色柏油路也当成积水。project指定输出目录训练产生的权重文件和日志都会放在这里。4.3 训练日志怎么读从这三个指标判断是否要提前停YOLOv8 训练时终端会滚动输出 box_loss、cls_loss、dfl_loss 和 mAP0.5 这几个指标。很多新手只看 mAP但 mAP 在 epoch 40 到 80 之间经常出现平台期这时候不要立刻终止训练要看 box_loss 是否还在持续下降。两个策略一是用早停机制patience20连续 20 个 epoch 验证 mAP 不涨就停二是自己在训练中段保存 checkpoints然后取 mAP 最高的那个权重文件做测试而不是默认用最后一轮。我在这份数据上常见的现象是cls_loss在 30 轮之后降得很慢但box_loss一直在降此时不必担心说明模型对目标位置的拟合还在继续。真正需要警惕的是box_loss从 epoch 60 开始回升同时验证集 mAP 下滑这通常意味着过拟合或数据泄漏回头去查划分阶段是不是把同路段图片拆开了。5. 避坑指南标注错位、积水反光和夜间漏检一个个给你拆开5.1 标注框覆盖不全模型学到的是“半个积水”现象推理结果里模型对积水的预测框总是只框住积水的亮色主体边缘往外扩散的浅水层被漏掉导致准确率看起来不高。原因原始数据集里部分积水的标注框偏紧只框了中心深色区域。YOLO 格式的预测框天生倾向于拟合训练框的边界标注紧则预测也紧。解决针对这类情况我在训练时把标注框统一外扩 8%~12%。操作上写个脚本把 txt 里的中心坐标保持不变、宽度高度各乘 1.1然后重新校验坐标不超过 1。def expand_boxes(label_path: str, expand_ratio: float 1.1): with open(label_path, r, encodingutf-8) as f: lines f.readlines() new_lines [] for line in lines: parts line.strip().split() if len(parts) ! 5: continue cls_id, cx, cy, w, h parts[0], float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) w_new min(w * expand_ratio, 1.0) h_new min(h * expand_ratio, 1.0) new_lines.append(f{cls_id} {cx} {cy} {w_new:.4f} {h_new:.4f}\n) with open(label_path, w, encodingutf-8) as f: f.writelines(new_lines)注意外扩不是万能的。这是我在第一次处理道路数据集时学到的教训——盲目统一外扩会导致两个挨得近的坑洼框重叠严重模型想学出“两个目标”却因为框相交而学成“一个目标”。所以外扩只建议用在稀疏场景的样本上密集坑洼区域不处理。5.2 积水和阴影的混淆看起来像黑色路径的玄学现象模型把树影、路缘石阴影误判成积水尤其雨后天晴时误检率能到 20% 以上。原因积水和阴影在 RGB 空间里都是低亮度、低饱和区域YOLO 这类单阶段检测器在浅层特征上很难区分纹理差异。如果训练集里雨天样本多、晴天阴影样本少模型自然会把“大块暗色区域”当成积水。解决第一优先是在数据集里补充阴影正样本——也就是把晴天树影标注为背景目标让模型有负样本可学。但这份数据本身没有专门标阴影类别实际操作时我把包含明显阴影的图片单独挑出来用 Mosaic 增强时保证每张训练图里至少含一张阴影图。我的一个习惯做法是训练时开启close_mosaic10也就是最后 10 个 epoch 关闭 Mosaic避免模型在融合图像上适应了背景纹理之后回到真实单图时反而不利于边界区分。5.3 夜间样本过少模型对路灯下的积水几乎失明现象白天 mAP 0.7夜间只有 0.3且夜间漏检集中在远离路灯的区域。原因这套数据的夜间样本占比通常不足 15%积水在夜间反光强烈样本又少模型没办法同时学会两种光照形态。解决两类方案并用。第一把夜间积水的图像做尺寸自适应增广在训练时用degrees15和translate0.1让模型对拍摄角度和位置偏差鲁棒一点。第二我手动做了一版“相对亮度增强”将夜间样本像素值做线性拉伸把积水区域和路面的对比度拉大再用增强后的图替换原图参与训练。这个操作不开任何新库用 PIL 就能完成。5.4 训练时验证集 mAP 很高推理时同一台设备上效果突然变差现象训练日志显示 mAP0.5 有 0.82导出 ONNX 后在 Jetson 上跑同一批测试图mAP 掉到 0.6。原因训练时顺手开了fliplr0.5但推理时输入没有做同样的水平翻转。YOLOv8 导出模型后默认不包含测试时增强左右翻转导致的分布差异直接暴露。坑洼积水数据集里很多道路图像元素在翻转前后语义不变但位置关系会变模型在训练时依赖了翻转增强学到的对称性。解决训练阶段把增强参数和推理阶段的预处理严格对齐。如果目标是部署到边缘设备我通常会用更大的批量尺寸让 BatchNorm 统计量尽量平滑然后导出时指定imgsz为部署分辨率避免推理时重新缩放出错。5.5 标签文件里混入单个坐标越界的框导致训练直接崩溃现象执行训练命令后几秒内报RuntimeError: CUDA error: device-side assert triggered定位问题在数据加载阶段。原因某张图片的标签里出现x_center width / 2 1.0的情况标注工具在处理图像边界附近的积水时没有裁剪框坐标直接超界。解决做一个全局巡检把所有超出边界的框按边界截断到 0.9999这是我每次拿到数据集都会无条件先跑一遍的脚本。def clamp_labels(label_path: str): with open(label_path, r, encodingutf-8) as f: lines f.readlines() new_lines [] for line in lines: parts line.strip().split() if len(parts) ! 5: continue cls_id parts[0] cx, cy, w, h map(float, parts[1:]) x_min max(0.0, cx - w / 2) y_min max(0.0, cy - h / 2) x_max min(1.0, cx w / 2) y_max min(1.0, cy h / 2) if x_max x_min or y_max y_min: continue new_cx (x_min x_max) / 2 new_cy (y_min y_max) / 2 new_w x_max - x_min new_h y_max - y_min new_lines.append(f{cls_id} {new_cx:.4f} {new_cy:.4f} {new_w:.4f} {new_h:.4f}\n) with open(label_path, w, encodingutf-8) as f: f.writelines(new_lines)逻辑说明先把框还原成左上角和右下角坐标裁到图像范围后重新算中心、宽和高。如果裁剪后宽或高为 0说明这个框本身无效直接丢弃。这段代码在所有数据集上都通用所以我把它写成了一个独立工具函数每次开始训练之前跑一遍。6. 验证与进阶用混淆矩阵和逐类 AP 决定模型能不能上线6.1 验证不只跑一句命令还要把输出文件读透当训练完成runs/pothole_water/train/weights/best.pt会生成。验证时我会单独创建一个干净的 val 流程yolo detect val \ datadata_pothole_water.yaml \ modelruns/pothole_water/train/weights/best.pt \ imgsz800 \ splitval \ project./runs/eval \ namepothole_water_val跑完后YOLOv8 会在输出目录生成 confusion_matrix.png、PR_curve.png 和 results.csv。其中最简单的判断方法是打开混淆矩阵图像看积水这一类是不是大片被预测成背景。如果是说明模型对积水的敏感度不够处理方式是下调置信度阈值再测一轮如果积水被预测成坑洼说明两类目标在特征空间上重叠严重需要考虑给模型加一层语义分割头或者接受这一混淆并调整业务过滤逻辑。6.2 用脚本逐类统计 AP找出拖后腿的类别YOLOv8 自带的验证结果里会打印每个类别的 AP 值但只在终端输出一次想保存下来逐轮对比不方便。我习惯写一个简单脚本读取 results.csv把每个类别的 AP0.5 单独抽出来看import csv from pathlib import Path results_file Path(./runs/eval/pothole_water_val/results.csv) with open(results_file, r, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: print(fepoch: {row[epoch]}, fmAP0.5: {row[metrics/mAP50(B)]:.4f}, fmAP0.5:0.95: {row[metrics/mAP50-95(B)]:.4f})如果验证集整体 mAP 高但metrics/mAP50-95(B)偏低说明预测框和真实框的交并比不高也就是定位精度不足。对这个数据集的场景我的经验是优先增大imgsz到 960其次调整anchor的宽高比。坑洼和积水的框大多是横向扁矩形默认 anchor 偏正方形匹配率会偏低。6.3 推理可视化与误检排查不要只看框要看框里的纹理最后一步是在测试集上跑推理输出带置信度的可视化结果图然后人工扫一遍误检样例。yolo predict \ modelruns/pothole_water/train/weights/best.pt \ source./test_images \ conf0.4 \ save_txtTrue \ save_confTrue \ project./runs/predict \ nametest_output我习惯把conf从 0.4 一步步往下调每档看一轮误检数量。在坑洼积水检测上conf0.4是一个相对安全的起点——低于 0.3 阴影误检会大量冒出来高于 0.5 夜间积水漏检又会变多。如果业务上允许两阶段确认我会保存低置信度目标为待确认列表由后续帧关联判断而不是单纯调低阈值。从那以后我每次拿到新的道路场景检测数据集都强制自己走一遍完整流程先清标签、再按路段划分、跑基线、看混淆矩阵最后才调超参。这套流程帮我避免了至少三次“训练一时爽、上线火葬场”的尴尬。坑洼积水这类任务真正考验的不是模型结构多先进而是你对数据分布的理解和对标注噪声的容忍度。希望这份拆解能让你少走一段弯路把这套数据集的复用价值真正压榨出来祝顺利。本文还有配套的精品资源点击获取