简介:面向下水管道缺陷智能检测任务,这份数据集内含1717张清晰的道路/管道内部缺陷图像,覆盖穿入、错口、堆积、垃圾、裂缝、泥土、树根共7类常见问题,全部以矩形框完成标注,总计3401个目标框,可直接用于YOLO、Faster R-CNN等目标检测模型的训练与评估。压缩包约61.64MB,共2000个文件,核心为JPEG图片、VOC格式XML标注与YOLO格式TXT标注,附类别对照说明与数据集说明;JPEGImages、Annotations、labels三个目录分工明确,classes.txt清晰说明类别顺序,便于快速切换不同深度学习框架的数据格式,并避免标签混淆。已有155人学习下载,适合从事市政管道运维、智能巡检系统研发的算法工程师与学生使用。图片清晰且未做数据增强,保留了真实采集场景的原始信息;7类缺陷样本分布较全,可帮助算法人员省去自行采集与清洗数据的时间,快速验证缺陷检测算法在真实场景下的效果,也可作为课程设计、算法比较和迁移学习的基准数据。
1. 1717张下水道缺陷图,为什么值得先看标注格式再动手训练
做市政管道检测的人拿到这个数据集,第一反应通常是赶紧解压、配环境、跑训练。但我的建议是先别急——这个标题里真正值钱的不是"1717张"这个数量,而是"YOLO+VOC格式"这六个字。下水道缺陷检测是个典型的垂直场景目标检测任务,缺陷类别长什么样、标注框怎么画的、类别ID怎么排的,这些直接决定你后面几周是顺利收敛还是反复翻车。这个数据集共1717张图、7类缺陷,同时给了YOLO的txt和VOC的xml两种标注,适合用来做迁移学习、算法比对,或者作为你们自有数据集的补充训练集。这篇笔记就围绕这套数据,讲清楚怎么读懂它的标注、怎么转格式、怎么训出能用的模型,以及最容易踩的坑在哪。
2. 从市政检测口径到YOLO类别ID:7类缺陷的标注逻辑
2.1 7类缺陷怎么来的:工程口径和数据口径不是一回事
下水道管道缺陷检测的类别定义,最早可以追溯到城镇排水管道检测评估的技术规程,里面把结构性缺陷和功能性缺陷分得很细,常见的有裂纹、腐蚀、变形、错口、渗漏、堵塞、破损等。但工程标准里的类别和数据集里的类别并不是严格一一对应的——数据集制作者往往会根据实际采集到的图像质量、标注成本、以及下游业务是否需要区分来做合并。
比如说,规程里"破裂"可能分环向破裂和纵向破裂,但数据集里可能直接合并成一个"破损"类。又比如"渗漏"在井下CCTV图像里往往表现为水渍或泥痕,和"腐蚀"的视觉特征很接近,标注时如果标准不统一,模型学起来就会混淆。所以我拿到任何数据集的第一件事,是先把类别清单和每类的样本数拉出来。
# 统计VOC格式下各类别的样本数(xml目录) grep -h "<name>" labels/*.xml | sort | uniq -c | sort -rn这条命令把每个xml里的类别名提取出来,统计每类出现的标注次数,而不是图片张数。注意一张图里可能同时有多种缺陷,所以这个数字会大于1717。这样你能立刻判断类别是否均衡——如果某一类只有几十个框,后面训练时就要重点关照。
2.2 VOC格式的XML里到底存了什么
VOC格式的核心是一个xml文件对应一张图片,文件名和图片名相同。打开一个标注文件,你看到的应该是这个结构:
<annotation> <folder>JPEGImages</folder> <filename>img_0042.jpg</filename> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <object> <name>crack</name> <bndbox> <xmin>512</xmin> <ymin>380</ymin> <xmax>760</xmax> <ymax>520</ymax> </bndbox> </object> </annotation>这里有两个关键信息需要你特别注意。第一,<size>节点里的图片宽高是原始图的尺寸,但实际图片文件可能是经过缩放或压缩的,如果两者不一致,后面转YOLO时坐标就会整体偏移。我一般会写个脚本用OpenCV读一下真实图片尺寸和xml里的size做对比,跑完发现不一致再决定以哪个为准。第二,<bndbox>给的是像素坐标系下的左上角和右下角,是绝对坐标,不是归一化的。这就是转换脚本要处理的核心内容。
2.3 category到class_id的映射:最容易埋雷的地方
VOC格式里类别是字符串,而YOLO格式里类别是整数ID。这个ID的顺序完全由你自己定——或者由数据集的classes.txt文件定。同样的"crack",在A数据集的ID是0,在B数据集里可能变成4。如果你直接拿A数据集的txt标注去训练B数据集的yaml配置,模型学到的是错乱的信息,而且很可能不报错、只是mAP很低,属于典型的"黑匣子问题"。
| 类别名 | 常见ID | 说明 |
|---|---|---|
| crack | 0 | 裂纹是最常见的缺陷,样本数通常最多 |
| corrosion | 1 | 腐蚀特征类似锈迹或剥落,容易和渗漏混淆 |
| deformation | 2 | 管道变形,通常表现为截面形状异常 |
| displacement | 3 | 错口,两节管道的接口处偏移 |
| blockage | 4 | 堵塞,异物或淤泥堆积 |
| infiltration | 5 | 渗漏,水渍、泥痕 |
| damage | 6 | 破损,结构性破裂 |
提示:拿到数据集先打开
classes.txt或yaml确认ID排序,不要凭文件名猜。这一步错了,后面所有训练和评估都白跑。
3. 把VOC标签转成YOLO的txt:最小脚本与三个必调参数
3.1 先搞懂YOLO标注格式的坐标换算
YOLO的txt每行对应一个目标框,格式是:class_id x_center y_center width height,其中四个坐标值都是相对于图片宽高的归一化比例,取值范围在0到1之间。转换的核心就是拿VOC里的xmin, ymin, xmax, ymax做一次除法:
x_center = ((xmin + xmax) / 2) / img_width y_center = ((ymin + ymax) / 2) / img_height width = (xmax - xmin) / img_width height = (ymax - ymin) / img_height注意是中心点坐标加宽高,不是左上角加右下角。很多新手第一次写转换脚本会误写成xmin / width和ymin / height,那得到的是左上角的归一化坐标,模型训练时loss能降但检测框永远偏一格。
3.2 转换脚本:直接可用的最小实现
下面这份脚本基于Python标准库xml.etree.ElementTree完成解析,不依赖第三方库,在任何有Python的环境里都能直接跑。
import os import xml.etree.ElementTree as ET from PIL import Image # 三个必调参数 xml_dir = "Annotations" # VOC xml所在目录 img_dir = "JPEGImages" # 图片所在目录 out_dir = "labels_yolo" # 输出txt目录 class_list = ["crack", "corrosion", "deformation", "displacement", "blockage", "infiltration", "damage"] os.makedirs(out_dir, exist_ok=True) def convert_one(xml_path, img_path, out_path): # 用PIL读图片尺寸,优先以真实图片为准 with Image.open(img_path) as im: img_w, img_h = im.size tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.findall("object"): name = obj.find("name").text if name not in class_list: print(f"跳过未知类别: {name} in {xml_path}") continue cls_id = class_list.index(name) box = obj.find("bndbox") xmin = float(box.find("xmin").text) ymin = float(box.find("ymin").text) xmax = float(box.find("xmax").text) ymax = float(box.find("ymax").text) # 边界保护:防止标注越界导致训练时出NaN xmin = max(0, min(xmin, img_w - 1)) xmax = max(0, min(xmax, img_w - 1)) ymin = max(0, min(ymin, img_h - 1)) ymax = max(0, min(ymax, img_h - 1)) x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") with open(out_path, "w") as f: f.write("\n".join(lines)) # 主流程:遍历xml目录 for filename in os.listdir(xml_dir): if not filename.endswith(".xml"): continue stem = filename[:-4] xml_path = os.path.join(xml_dir, filename) img_path = os.path.join(img_dir, stem + ".jpg") out_path = os.path.join(out_dir, stem + ".txt") if not os.path.exists(img_path): print(f"图片不存在,跳过: {img_path}") continue convert_one(xml_path, img_path, out_path) print("转换完成")这段代码的逻辑分四块:读图片尺寸、解析xml里的每个object、对坐标做越界保护、写成YOLO格式的txt。参数方面你需要改的是class_list的顺序——这个顺序决定了每个类别最终的ID,必须和你后面训练用的yaml保持一致。img_dir和out_dir建议用绝对路径,避免在不同目录下运行脚本时出现找不到文件的情况。
3.3 边界坑:图片尺寸不一致、多标签、空标注
第一个坑是图片尺寸。有些数据集xml里的<size>是标注工具读的,但图片后来被压缩过,两者不一致。我的做法是上面脚本里的方式:始终以PIL实际读到的图片宽高为准。代价是每张图都要读取一次,1717张图大约多花十几秒,但换来的坐标准确性值得。
第二个坑是多标签图片。一张污水管图片里可能同时有裂纹和腐蚀两个缺陷,一图多object是常态。上面的脚本用lines列表存储每行输出,天然支持多标签,不需要额外处理。
第三个坑是空标注文件。有些图片没有任何缺陷,xml里没有<object>节点。转换后生成的txt是空文件,这是正常现象,但要确保训练时这些空txt对应的图片没有被误删。Ultralytics YOLO训练时会自动跳过没有标签的图片,但如果你用os.listdir做二次处理,别把空文件视为异常。
3.4 转换对不对?画个框验证一下
转换完成后一定要可视化验证,这一步能救你半天调试时间。写个脚本读取转换后的txt,在原图上画框,抽查10张图人工确认。
import cv2 def draw_yolo_boxes(img_path, txt_path, class_list, out_path): img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path) as f: for line in f.readlines(): line = line.strip() if not line: continue cls_id, xc, yc, bw, bh = map(float, line.split()) x1 = int((xc - bw / 2) * w) y1 = int((yc - bh / 2) * h) x2 = int((xc + bw / 2) * w) y2 = int((yc + bh / 2) * h) color = (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, class_list[int(cls_id)], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imwrite(out_path, img) # 抽查前5张 for i in range(5): draw_yolo_boxes(f"images/img_{i:04d}.jpg", f"labels_yolo/img_{i:04d}.txt", class_list, f"check_{i}.jpg")如果画出来的框大面积偏移,优先怀疑坐标转换公式或图片尺寸读取方式;如果只有个别图片偏移,重点检查那些图片是否被裁剪过或旋转过。这个抽验步骤花不了5分钟,但能避免你带着错误标注跑十几个小时的训练。
4. 用YOLOv8把1717张图跑起来:目录、划分、训练命令
4.1 标准目录结构:Ultrlytics YOLO要求这样组织
数据准备完,下一步是把数据组织成训练框架要求的目录结构。以Ultralytics YOLOv8为例,标准的数据集目录是images和labels两个平行目录,各自下面再分train和val子目录:
dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yaml图片放在images/train里,对应的txt标注放在labels/train里,文件名必须完全一致(只差后缀)。训练时框架按文件名前缀自动匹配图片和标签,文件名对不上就直接跳过,而且不会报严重错误,只在日志里打个警告。
4.2 1717张图的划分:固定随机种子,别玄学
1717张图按8:2划分,训练集约1374张,验证集约343张。划分时要确保同一张图片的jpg和txt被分到同一边,并且固定随机种子,保证每次划分结果一致,方便后续对比实验。
import os import random import shutil random.seed(42) # 固定种子,保证可复现 image_dir = "JPEGImages" label_dir = "labels_yolo" train_img_dir = "dataset/images/train" val_img_dir = "dataset/images/val" train_lbl_dir = "dataset/labels/train" val_lbl_dir = "dataset/labels/val" for d in [train_img_dir, val_img_dir, train_lbl_dir, val_lbl_dir]: os.makedirs(d, exist_ok=True) files = [f for f in os.listdir(image_dir) if f.endswith(".jpg")] random.shuffle(files) val_count = int(len(files) * 0.2) val_files = files[:val_count] train_files = files[val_count:] for f in train_files: stem = f[:-4] shutil.copy(os.path.join(image_dir, f), train_img_dir) shutil.copy(os.path.join(label_dir, stem + ".txt"), train_lbl_dir) for f in val_files: stem = f[:-4] shutil.copy(os.path.join(image_dir, f), val_img_dir) shutil.copy(os.path.join(label_dir, stem + ".txt"), val_lbl_dir) print(f"训练集: {len(train_files)} 张, 验证集: {len(val_files)} 张")这里random.seed(42)是关键,不固定种子的话每次跑的划分结果都不一样,你很难判断模型效果变好是数据划分变了还是训练参数变了。另外我建议用shutil.copy而不是os.rename,保留原始目录的完整数据,以便随时重新划分。
4.3 data.yaml与训练命令:先跑小模型验证流程
接下来创建data.yaml,内容是类别列表和路径。注意路径建议用绝对路径,或者相对于data.yaml文件所在位置的相对路径。
path: /home/user/dataset train: images/train val: images/val names: 0: crack 1: corrosion 2: deformation 3: displacement 4: blockage 5: infiltration 6: damage然后跑训练命令:
yolo detect train \ data=data.yaml \ model=yolov8n.pt \ epochs=100 \ imgsz=640 \ batch=16 \ workers=4 \ device=0几个关键参数说明:model=yolov8n.pt是从官方预训练权重开始迁移学习,n是nano版本,速度最快,适合先验证流程。你完全可以直接用yolov8s或m,但从n开始跑通整个pipeline再换大模型,是更省时间的做法。imgsz=640是训练时缩放到640x640,下水道缺陷检测不需要处理极小目标,640够用。batch=16取决于显存大小,12GB显存跑nano模型可以开到32或者更大,但显存不足时要减小,否则会显存溢出(OOM)。workers=4是数据加载线程数,Windows上如果报错就改成0或2。
4.4 数据增强参数:下水道这个场景要克制
YOLOv8默认开启了Mosaic增强,它会同时拼4张图合成一张训练样本。但下水道缺陷检测有个特殊问题:很多缺陷是长条形的,比如环向裂纹在管道里呈现弧形,拼接时容易把半截缺陷拼歪,让模型学到错误的形态。我一般会做两件事:
# 这些写在ultralytics的训练配置里,或者用命令行参数覆盖 mosaic: 0.5 # 默认是1.0,降低到0.5减少拼接概率 fliplr: 0.5 # 水平翻转可以保留 flipud: 0.0 # 垂直翻转建议关闭,管道图有重力方向语义 degrees: 0.0 # 旋转关闭,除非你的采集相机角度不固定垂直翻转在普通目标检测里常用,但下水道场景里"水渍在底部"和"水渍在顶部"不是同一类缺陷,强行翻转会让模型混淆语义。degrees旋转同理,井下CCTV相机一般是正置的,不需要旋转增强。这些属于"增强需符合物理语义"的典型案例——增强的目的是模拟真实采集分布,而不是制造现实里不会出现的伪样本。
5. 下水道缺陷训练最常踩的5个坑:现象、原因、解法
5.1 训练loss变成NaN
现象:训练到中途,loss突然变成nan,或者直接从第一个epoch就是NaN。
原因:最常见的是标注框越界或图片本身损坏。YOLO的损失计算里包含框的宽高,如果归一化坐标里出现了负数、大于1的数,或者宽高为0,梯度计算就会崩。
解决:回到转换脚本,检查输出txt里的数值范围。写一行Python快速扫一遍:
bad = [] for txt in os.listdir("labels_yolo"): with open(os.path.join("labels_yolo", txt)) as f: for line in f: parts = line.strip().split() if len(parts) != 5: bad.append(txt) break xc, yc, bw, bh = map(float, parts[1:]) if not (0 <= xc <= 1 and 0 <= yc <= 1 and bw > 0 and bh > 0): bad.append(txt) break print("异常文件:", bad)把异常文件对应的图片单独挑出来看,多半是原始标注框超出了图片边界,转换脚本里的clip操作没做好保护。
5.2 某一类缺陷完全学不会
现象:训练结束后各类的mAP差异巨大,某一类比如"渗漏"的AP接近0,其他类都正常。
原因:类别样本不均衡。1717张图里如果渗漏只有几十个标注框,模型可能压根没有学到它的有效特征。另一个常见原因是该类缺陷和别的类视觉上高度相似,比如"渗漏"和"腐蚀"在图像里都是深色斑块。
解决:先统计每类框的数量确认是否不均衡。如果确实少,措施按优先级排列:第一,收集更多该类数据;第二,用数据增强让该类样本参与更多的增强变换;第三,如果只有几十个框,干脆把那两类合并成一个业务类别,不要为了凑7类而硬训一个学不会的类。作为工程方案我一般选第三——训练出一个"能区分大多数缺陷但有缺陷漏检可控"的模型,比强行追求类别齐全更重要。
5.3 mAP不低,但实拍视频里漏检一堆
现象:验证集mAP@0.5有0.8以上,但拿到实际管道检测视频里跑,大量缺陷框不出来。
原因:验证集和真实场景分布不一致。这个数据集里的图片多半是CCTV探头近距离拍摄的清晰画面,而实际部署时可能有灯光不足、镜头有泥浆、运动模糊等情况。另外训练时的imgsz=640,如果推理时输入分辨率更大,模型可能不适应。
解决:这类问题属于典型的"能跑通和能用是两回事"。做法是收集一小段实际采集视频,抽帧后人工标注几百张,加到验证集里看模型真实水平,然后再针对性做域适应——比如把训练数据做亮度抖动、加模糊模拟。如果没有预算做二次标注,至少要把推理时的imgsz调整到和训练一致,并测试置信度阈值,不要默认用0.25。
5.4 zip解压后路径带中文或空格导致加载失败
现象:解压数据后,训练时提示部分图片找不到,或者Image.open报错。
原因:数据包是从压缩包解压出来的,目录或文件名里带了中文、空格、特殊字符。很多标注工具生成的文件名是中文日期或项目编号,而YOLO系框架按空格分隔路径,遇到空格就把路径截断了。
解决:拿到的第一件事就是做路径规范化——把所有图片和标注文件的文件名改成img_0001.jpg这种纯英文数字格式。
import os import re for dirpath, _, filenames in os.walk("dataset"): for f in filenames: new_name = re.sub(r"[^a-zA-Z0-9._]", "_", f) if new_name != f: os.rename(os.path.join(dirpath, f), os.path.join(dirpath, new_name)) print(f"重命名: {f} -> {new_name}")把这段脚本放在解压之后、训练之前执行,能避免一大半莫名其妙的路径报错。
5.5 用其他仓库训练时类别ID错乱
现象:换了一个检测框架(比如从Ultralytics换到MMDetection),或者换了同事的yaml文件,训练出来的模型在推理时框的位置是对的,但类别标签总是对不上。
原因:不同框架、不同数据集的类别ID排序不是标准化的。同一张图片在A框架里"crack"是0,在B框架里"corrosion"是0,模型学到的输出头是按框架配置来的。
解决:每次切换框架或换yaml时,都要做一次"ID即类别名"的验证。方案是随机抽一个已知类别的图片,推理后打印检测结果的类别名,人工确认,不要只看数值。这种错乱问题有个特点:loss正常下降、mAP正常计算,你如果不做人工抽查,可能一直到部署阶段才发现预测标签全乱了。
6. 验证不止看mAP:混淆矩阵、阈值和漏检回捞
训练结束后,别急着高兴,用验证集做一次细致的诊断比单纯看mAP更有价值。第一步是生成混淆矩阵,看哪两类缺陷最容易互相混。下水道场景里最典型的混类是"渗漏"和"腐蚀"、"裂纹"和"破损"——它们在局部纹理上确实像。混淆矩阵里如果某个非对角线元素特别高,说明这两类的视觉边界需要重新定义,要么增加标注量,要么考虑合并类别。
第二步是调置信度阈值。YOLO默认的conf=0.25在通用场景下够用,但下水道缺陷检测的漏检代价高、误检代价低——漏掉一个裂纹可能引发管道事故,多报一个腐蚀顶多多花时间复核。所以业务上我往往会调低到0.1或0.15,让模型多出框,再用人工审核兜底。这是检测算法落地时典型的"召回优先"策略。
第三步是针对管道图像做推理时增强。井下图片常常光照不均匀,推理时把图片水平翻转一次、再对两次结果做NMS合并,通常能多捞回几个漏检的暗部缺陷。脚本如下:
model = YOLO("best.pt") img = cv2.imread("test.jpg") flipped = cv2.flip(img, 1) res1 = model(img, conf=0.15) res2 = model(flipped, conf=0.15) # 把res2的框还原到原图坐标后与res1做NMS合并 # 具体用torchvision.ops.nms即可这套思路适合小规模验证,真正上线时一般用TensorRT做加速优化。我的习惯是训练完成后,永远先跑完这套诊断再加部署,跑混淆矩阵、调阈值、做TTA验证,哪怕多花一小时,也好过到现场发现问题再回来调整。希望这套流程能帮你把这个数据集的价值榨干,少走几周弯路。
本文还有配套的精品资源,点击获取