☰
管道缺陷检测目标检测实战:数据集标注、切分与增强避坑
2026/10/9 21:55:54 网站建设 项目流程

简介:针对管道运维与安全监测需求,面向目标检测与工业视觉应用的一份管道缺陷检测数据集,超过900张带标签图片,适合研究人员与工程师直接用于YOLO系列模型的训练与验证。数据处理成YOLO格式,并做了数据增广,内置训练集、验证集和类别文件,类别信息集中在defect等标签中。压缩包共1893个文件,其中以946个txt标签与945个jpg图片为主体,另有1个py脚本和1个png示意图,整体约32.47MB,目录结构清晰,便于直接接入训练流程。show脚本可以把检测框绘制在图像上,用于快速核对标注质量、观察目标分布;针对裂纹、锈蚀、凹陷等典型工业表面异常,可直接基于该数据构建检测模型。目前已有1940人学习下载,适合希望快速搭建缺陷检测基线、验证数据增强策略、开展目标检测实验,或进行工业质检算法预研的开发者。

1. 管道缺陷检测数据集:拿到 900 张目标检测标注图,先别急着训练

管道缺陷检测数据集一共超过 900 张带标签图片,是目标检测方向里难得能直接落地的标注资源。第一次拿到这份数据,我跟大多数人的想法一样,先解压再看图,然后满怀期待打开训练脚本。真正跑过一轮以后才发现,900 张图能不能撑起一个可用的检测模型,取决于你是否先回答三个问题:标签坐标是绝对坐标还是归一化坐标,缺陷类别分布是否均衡,训练集和验证集有没有做同源隔离。这份资源覆盖了燃气、排水、工业管道巡检里常见的表面缺陷类型,适合用来训练 YOLO、Faster R-CNN 这一类的目标检测模型。无论你是想快速搭一个管道缺陷识别 Demo,还是要为巡检系统补充训练样本,都值得先把这套数据和标签完整地跑一遍,再决定后面怎么调。

2. 先拆数据目录:标注格式、类别分布与图像质量三件事

拿到数据包以后,第一件事不是把图片全部扫一遍,而是把目录结构和标注文件过一遍。图片看起来差不多,但标注可能是 VOC 的 XML、COCO 的 JSON,也可能是 YOLO 的 TXT,三者的坐标含义完全不同。如果不对格式做确认就直接写加载器,很容易读到一半报越界,或者在坐标换算时把框画到图片外面。下面是我通常的处理顺序,每一步都是为了后面训练时少出问题。

2.1 目录组织:一张图对应一个标签的对应逻辑

数据解压后先用一条树命令看看整体组织:

tree -L 2 pipe_defect_dataset/

常见的目录结构是这样:

pipe_defect_dataset/ ├── images/ │ ├── 0001.jpg │ ├── 0002.jpg │ └── ... ├── labels/ │ ├── 0001.xml │ ├── 0002.xml │ └── ... └── classes.txt

images 和 labels 目录下的文件名一一对应,只是扩展名不同。加载时用图片的 stem 去找同名标签,这种结构最省心,也是我接触到的大多数标注数据集的常见组织方式。还有另一种组织方式是标签全部集中在 CSV 或 JSON 里,图像文件和标签分开存放,这种情况下需要先读索引字段才能建立图片和标注的关联。

不管哪种组织,我建议先看一套图片和它对应的标签文件内容,确认格式后再设计加载逻辑。尤其要注意 labels 目录里如果混着 .txt 和 .xml,不要靠猜,先跑一遍扩展名统计。

from pathlib import Path from collections import Counter label_dir = Path("pipe_defect_dataset/labels") ext_counter = Counter(p.suffix.lower() for p in label_dir.iterdir()) print(ext_counter)

这段脚本统计 labels 目录下每种扩展名的文件数量。输出类似 Counter({'.xml': 850, '.txt': 80}),说明多数图片是 XML 标注,少量是新补充的 TXT 标注,那么加载器就要同时兼容两种格式。注意 Path.iterdir() 会遍历目录里所有文件,如果 labels 目录混入了非标注文件,统计结果会被污染,需要先人工看一眼输出再决定是否过滤。

2.2 标注坐标体系:像素坐标与归一化坐标的换算

XML 格式通常对应 Pascal VOC,坐标是像素绝对值,一个对象的标注写成 xmin、ymin、xmax、ymax 这组值。TXT 格式通常对应 YOLO,一行五个数:类别编号、中心点 x、中心点 y、框宽、框高,其中中心坐标和宽高都相对图片尺寸做了归一化。这两种格式之间的换算是高频操作。

下面这段代码把 YOLO 一行标注转回像素坐标:

def yolo_to_pixel(img_w, img_h, line): # line 形如 "0 0.5201 0.4812 0.1120 0.0945" parts = list(map(float, line.strip().split())) class_id = int(parts[0]) xc, yc, bw, bh = parts[1:] x1 = (xc - bw / 2) * img_w y1 = (yc - bh / 2) * img_h x2 = (xc + bw / 2) * img_w y2 = (yc + bh / 2) * img_h return class_id, x1, y1, x2, y2

注意 YOLO 的归一化是相对图片宽高求比值,转回像素坐标必须拿到图片真实的宽高。如果标签生成时用了某个固定尺寸,而后期图片被 resize 过,归一化值仍然有效,所以最好始终使用当前图片的实际宽高。换算结果通常不是整数,在保存和绘制时不要立刻 int() 截断,等最终画框再做一次类型转换,否则框会偏移一到两个像素。

2.3 类别分布统计:看清哪些类是大头,哪些类几乎没样本

拿到标注以后,我会先统计每个类别出现的次数。这个操作看起来简单,却能暴露很多问题:如果某个类只出现了十几次,训练时大概率学不充分,后面切分时需要特殊处理。

对于 VOC 格式的 XML,直接用标准库解析:

import xml.etree.ElementTree as ET from collections import Counter def parse_voc_xml(xml_path): # 解析 VOC XML 标注,返回 (类别名, x1, y1, x2, y2) 列表 tree = ET.parse(xml_path) root = tree.getroot() boxes = [] for obj in root.iter("object"): name = obj.findtext("name").strip().lower() bbox = obj.find("bndbox") boxes.append(( name, float(bbox.findtext("xmin")), float(bbox.findtext("ymin")), float(bbox.findtext("xmax")), float(bbox.findtext("ymax")), )) return boxes all_boxes = [] for xml_file in label_dir.glob("*.xml"): all_boxes.extend(parse_voc_xml(xml_file)) name_counter = Counter(box[0] for box in all_boxes) print(name_counter)

如果标签是 YOLO 格式的 TXT,里面只存类别编号,没有类别名,需要先读 classes.txt 把编号映射成名称。把上面的解析函数改成按行读取 TXT,再用 yolo_to_pixel 换算,逻辑是一样的。这里最要注意的不是统计结果,而是类别名不一致问题:有的标注写 Crack,有的写 crack,还有的写 裂缝。先确认 classes.txt 里有没有统一规范,没统一就自己维护一张映射表,在训练前把所有标签重写一遍。

2.4 图像分辨率与缺陷尺度:判断你有没有小目标问题

管道缺陷在整幅图像里经常只占很小一块区域,焊缝裂纹可能只有几十个像素宽。如果数据里大量标注框面积小于 32 乘 32,后续检测模型的下采样倍数会直接影响这类目标的召回率。

from PIL import Image small_box_count = 0 total_box_count = 0 for xml_file in label_dir.glob("*.xml"): img_file = img_dir / (xml_file.stem + ".jpg") if not img_file.exists(): img_file = img_dir / (xml_file.stem + ".png") with Image.open(img_file) as im: img_w, img_h = im.size for _, x1, y1, x2, y2 in parse_voc_xml(xml_file): total_box_count += 1 if (x2 - x1) < 32 or (y2 - y1) < 32: small_box_count += 1 print("small ratio:", small_box_count / max(total_box_count, 1))

32 是一个经验阈值,不是固定标准。下采样 32 倍的网络,小于 32 像素的目标在最后一层特征图上只占一个点左右,检测几乎不可能稳定。如果小目标比例高,后面做增强时就要控制随机裁剪的范围,避免把已经很小的缺陷裁掉一半。

注意:统计小目标比例时,如果 total_box_count 为 0,说明加载路径写错了。先回到 2.1 步确认图片和标签文件的对应关系,再回来跑脚本。

3. 切分训练集与验证集:同源隔离与分层抽样

数据切分看起来是小事,实际是管道缺陷检测训练里最容易翻车的一环。很多项目组直接把图片随机打乱切分,训完验证集指标不错,部署到新管道上立刻失效,问题根源往往不在模型结构,而在数据切分阶段。

3.1 为什么不能只做随机打乱

管道缺陷图像经常来自一段段连续拍摄的视频,同一根管道相邻帧的背景、光照和缺陷形态都非常相似。如果直接随机切分,相邻帧很可能一个进训练集、一个进验证集。模型在验证集上看到的未知图片,背景和缺陷形态与训练样本基本同源,验证集失真,模型泛化能力被高估。部署到新场景时,光照条件、管道内壁材质、拍摄距离一变,模型表现就会明显下降。

所以切分之前先看文件名结构。如果文件名包含来源编号,比如设备编号加帧号,优先按设备维度切分,保证同一设备或同一管段的数据要么全部在训练集,要么全部在验证集,而不是两边都有。没有这类元数据时,至少把按序号连续的一段段图片看成同一个来源,按段切。

3.2 固定随机种子的基础划分脚本

下面是一个最基础的随机划分脚本,强调固定随机种子:

import random import math from pathlib import Path random.seed(42) # 固定种子,保证每次切分结果一致 img_dir = Path("pipe_defect_dataset/images") label_dir = Path("pipe_defect_dataset/labels") img_files = list(img_dir.glob("*.jpg")) + list(img_dir.glob("*.png")) random.shuffle(img_files) val_ratio = 0.2 val_num = int(math.floor(len(img_files) * val_ratio)) val_files = img_files[:val_num] train_files = img_files[val_num:] def write_list(file_list, output_path): with open(output_path, "w", encoding="utf-8") as f: for p in file_list: f.write(str(p.resolve()) + "\n") write_list(train_files, "train.txt") write_list(val_files, "val.txt") print(f"train: {len(train_files)}, val: {len(val_files)}")

随机种子固定成 42,保证每次切分结果完全一致。目标检测训练周期长,一旦发现数据划分有问题,用相同种子重新划分才能和上一轮结果做对比,也能让队友复现你的结果。val_ratio 设为 0.2,900 张图大约 180 张进验证集,够评估一类缺陷模型用;如果类别很稀疏,我会把验证集比例降到 0.15,多留一点训练样本。resolve() 把相对路径转成绝对路径,避免训练时因为当前工作目录变化而找不到文件。

3.3 稀有类别要做的分层抽样

如果统计发现某个缺陷类别只有一个巴掌数量的样本,随机划分后这些框可能全部落在训练集或全部落在验证集,导致验证指标失真。解决方法是先按类别对图片分组,再从每组里分别取验证集。

下面是按指定类别实行分层的脚本:

import xml.etree.ElementTree as ET def find_images_containing(label_dir, cls_name): result = [] for label_path in label_dir.glob("*"): if label_path.suffix == ".xml": tree = ET.parse(label_path) if any(obj.findtext("name").strip().lower() == cls_name for obj in tree.getroot().iter("object")): result.append(label_path) return result rare_class = "crack" # 换成你统计出来的稀有类别名 rare_images = find_images_containing(label_dir, rare_class) print(f"rare class images: {len(rare_images)}")

class 名要根据 2.3 的统计结果替换。找到包含稀有类别的图片后,按一定比例从 rare_images 里单独抽出验证集,再把这些图片排除出训练集;其他类别仍然按随机划分流程走。这一步解决了常见问题:稀有类别的小目标在验证集里一个都找不到,评估指标看起来是 0,其实不是模型没学好,而是划分时把样本全漏了。

3.4 划分之后先自查,再开训练

划分完成后不要急着启动训练。先检查训练集和验证集是否有文件名重复,再统计验证集类别分布,确认稀有类至少出现在验证集里。

val_stems = set(p.stem for p in val_files) train_stems = set(p.stem for p in train_files) dup = val_stems & train_stems if dup: print("duplicate stems:", dup) else: print("no duplicate, ready to train")

如果输出 duplicate stems 非空,先排查原因,可能是同一张图被复制到不同子目录,也可能是图片重命名后产生了同名文件。出现重复不想重命名的话,把重复文件直接从其中一个列表里删掉再做一次分布检查。这个动作虽然花不了两分钟,但能避免训练和验证数据重叠带来的一系列隐性评估问题。

4. 避坑:管道缺陷检测里最常见的五个翻车点

管道缺陷检测有自己的特殊性:管壁纹理复杂、缺陷尺度小、类别名称容易混乱。这里列五个我实际遇到过的问题,每一条都按 现象、原因、解决 三个层面拆开讲,供你排查自己数据时对照。

4.1 训练集 loss 掉得快,验证集 mAP 长时间不动

现象:训练几百个 epoch,训练集的分类和定位损失都明显下降,验证集 mAP 几乎不变,偶有抖动。

原因:最常见的并不是模型欠拟合,而是训练集和验证集划分时发生了同源泄漏。同一段视频的相邻帧被同时分进训练集和验证集,模型在验证集上看到的图像与训练样本极度相似,训练损失降低并不代表真实泛化能力提升,mAP 自然不随 loss 变化。

解决:回到第 3 章,按视频片段或设备编号做分组切分,让验证集里的图像来源和训练集完全不重叠。切分完成后再跑一遍训练,通常半个 epoch 就能看到验证指标开始随训练节奏变化。

4.2 验证集小目标缺陷几乎全部漏检

现象:验证时输出结果显示大的缺陷框能检出,小于 40 像素的缺陷漏检率接近百分之百。

原因:模型下采样倍率对标注框尺寸不匹配。以 N 倍下采样为例,小于 N 像素的小目标在最后一层特征图只占一个点,特征表达严重不足。另一个常见原因是训练时的随机裁剪把部分小目标裁出画面,模型见到的有效小目标样本更少了。

解决:先按 2.4 的脚本统计小目标比例。如果小目标确实多,把训练输入分辨率提高,或者调整训练阶段只对图片做等比例缩放不做随机裁剪,再配合 Mosaic 增强让每个 batch 里都有小目标样本。如果检测锚框机制支持手工指定锚框尺寸,把小尺寸锚框补进去。

4.3 训练到一半报坐标越界,训练进程直接崩溃

现象:模型加载数据时报错,提示某个 bounding box 的 xmax 大于图片宽度,或者出现负坐标,训练在数据增强阶段直接中断。

原因:标签框坐标超出图像边界。这种情况在人工标注边缘缺陷时经常出现,标注员把框画出画面;另一种原因是格式转换时对 yolo_to_pixel 的结果进行 int() 截断,导致坐标出现一两个像素偏移,在数据增强旋转后越界被放大。

解决:在数据加载管线里加一道坐标裁剪逻辑。框坐标统一与图像边界做 clamp,裁剪后如果框宽或框高小于 8 像素直接丢弃。注意这个滤波过程要放在增强之前,避免增强变换把原本在边界的框推到更危险的位置。

4.4 同一缺陷类别在不同图片里叫法不一致

现象:classes.txt 里定义只有三个类别,但统计后发现标注里出现了 crack、Crack、裂缝、craze 等多个相似名称,模型把同一个缺陷当成多个类别去学习。

原因:数据集可能是多人协作标注或者来源合并,不同标注人员习惯不同,有的写英文,有的写中文,有的加了下划线,训练框架按类别名处理时全部当成独立类别。

解决:先把 2.3 的输出完整列出来,人工确认哪些应该是同一类别,写一个类别名映射字典,重新生成标签文件。以 crack 为例,把本质上同一类缺陷的名称统一成一个 ID,再更新 classes.txt。这一步必须在训练前做,不然后面还要反复调整模型输出层。

4.5 模型把管壁纹理误识别成缺陷

现象:检测结果里频繁出现假阳性,尤其是焊缝、管壁阴影、积水反光容易被识别成缺陷,精确率上不去。

原因:训练样本里只包含了缺陷正样本,缺乏没有缺陷但外观显著的背景负样本。模型没有见过这类纹理,自然把特征接近的背景块当目标输出。

解决:管壁图像中如果存在大量不适合检测的背景帧,比如没有标注任何缺陷的帧,不要丢弃,把这些无标注样本作为纯背景加入训练数据,让模型学习这些负样本的特征。实际场景里我一般会准备一文件夹的背景帧,在训练配置里单独指定负样本路径,让 loss 在背景块上得到抑制。没有现成负样本时,把产生误检的验证集误报块截取出来,作为额外负样本加入下一轮训练。

5. 增强与标注同步变换:让 900 张图发挥更大价值

900 张图对深度学习来说不算多,但管道缺陷检测有个优势:目标形态相对固定,缺陷类别有限,正样本特征较集中。只要增强策略控制得当,900 张图可以撑住一版可用的检测模型。核心原则是增强操作不能改变缺陷类别的本体特征,尤其是裂纹的方向和纹理细节。

5.1 哪些增强适合管道内壁图

适合管道内壁图的增强包括小角度旋转、水平翻转、亮度对比度扰动、轻度模糊和灰度化。水平翻转对管道检测几乎无副作用,垂直翻转要谨慎,管道内壁的上下方向经常对应真实重力方向,积水、淤积类缺陷会因为垂直翻转丢失先验信息。

大角度旋转要控制边界,比如 ±90 度旋转会让裂纹方向变成随机方向,模型学到的是方向无关的特征,如果实际场景中裂纹方向与管轴存在固定关系,大角度旋转反而损害泛化。随机裁剪也要保守,管道图像里缺陷往往只占很小区域,过度裁剪容易把目标中心裁掉,导致增强后样本标签丢失。

5.2 色彩增强脚本与参数边界

管道内壁光照变化是实际场景中最大的干扰来源,所以色彩增强非常有效。用 HSV 空间做亮度饱和度扰动比较安全:

import cv2 import numpy as np def hsv_augment(img, hue_shift=5, sat_scale=1.1, val_scale=1.1): # 输入 BGR 图像,输出增强后的 BGR 图像 hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV).astype(np.float32) hsv[..., 0] = (hsv[..., 0] + hue_shift) % 180 hsv[..., 1] = np.clip(hsv[..., 1] * sat_scale, 0, 255) hsv[..., 2] = np.clip(hsv[..., 2] * val_scale, 0, 255) return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR)

hue_shift 控制色调偏移,管道内壁主色调偏灰黄,偏移量超过 10 会让颜色失真,所以我一般控制在 5 以内。sat_scale 和 val_scale 分别控制饱和度和亮度系数,1.1 表示原始值乘以 1.1,超过 1.3 后管壁纹理细节会丢。增强函数只处理图像,不与标注框发生交互,可以离线批量生成样本,也可以做成在线变换。

5.3 几何增强:旋转时同步修改标注框

几何增强最大的坑是只旋转了图片,没有同步更新标注框。下面这个函数用 OpenCV 的旋转矩阵同时处理图片和框:

def rotate_image_and_boxes(img, boxes, angle): # boxes: [(x1, y1, x2, y2), ...],像素坐标 h, w = img.shape[:2] matrix = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) rotated = cv2.warpAffine(img, matrix, (w, h), borderMode=cv2.BORDER_REFLECT) new_boxes = [] for x1, y1, x2, y2 in boxes: corners = np.array( [[x1, y1], [x2, y1], [x2, y2], [x1, y2]], dtype=np.float32, ).reshape(-1, 1, 2) rotated_corners = cv2.transform(corners, matrix).reshape(4, 2) rx1 = max(0, rotated_corners[:, 0].min()) ry1 = max(0, rotated_corners[:, 1].min()) rx2 = min(w, rotated_corners[:, 0].max()) ry2 = min(h, rotated_corners[:, 1].max()) if rx2 - rx1 < 8 or ry2 - ry1 < 8: continue # 旋转后框太小,丢弃 new_boxes.append((rx1, ry1, rx2, ry2)) return rotated, new_boxes

边框模式用的 BORDER_REFLECT,让旋转后边缘区域从相邻像素镜像填充,避免纯黑边框影响训练。旋转角度建议在正负 15 度之间。旋转后外接框必然比原来大,框尺寸过滤阈值设为 8 像素,太小的框在增强后已经丧失标注价值。如果数据是 YOLO 归一化格式,旋转完成后再把新框坐标除以新的图像宽高。

5.4 增强样本抽检与存档建议

增强不是生成完就结束。离线增强时,我习惯随机抽 5 到 10 张图片,把增强后的图片和标签框叠加绘制出来,肉眼确认框是否贴合缺陷。重点看旋转后框是否偏移、裁剪后缺陷是否完整、色相增强是否让缺陷边缘模糊。

在线增强则建议在训练配置里固定一组增强参数,不要每个 epoch 都改。把增强参数写在配置文件里,训练中断重开时保持一致,否则前一轮的结果和后一轮完全不可比。不要一次把所有增强手段都拉满,管道缺陷检测对纹理敏感,增得过猛会把缺陷特征洗掉,模型收敛更慢而不是更快。

6. 把第一轮预测结果画出来:用错误样本反推数据集问题

第一轮训练完成后,不要只看 mAP 数字,把预测框和真实框画到同一张图上,逐张看错在哪。这个环节比调参更能帮助你定位问题。

6.1 叠加绘制预测框和真实框

下面这个函数把预测框和标注框画在同一张图上:

import cv2 def draw_pred_vs_true(image_path, pred_boxes, true_boxes, save_path): # pred_boxes: [(x1, y1, x2, y2, score, cls_name)] # true_boxes: [(x1, y1, x2, y2, cls_name)] img = cv2.imread(image_path) for x1, y1, x2, y2, score, cls_name in pred_boxes: cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 0, 255), 2) cv2.putText(img, f"{cls_name}:{score:.2f}", (int(x1), max(0, int(y1) - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) for x1, y1, x2, y2, cls_name in true_boxes: cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.putText(img, cls_name, (int(x1), min(img.shape[0], int(y2) + 18)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imwrite(save_path, img)

红色是预测框,绿色是真实框。重点看三类错误:预测框和真实框完全错位,说明目标边界模糊或标注偏移;预测框有高置信度但真实框不存在,说明背景误检;真实框存在但预测框置信度很低,说明这类目标特征没有学好。

6.2 按缺陷类型归类错误,决定下一轮动作

把错误样本按缺陷类别归堆以后,处理方向就清晰了。如果错误集中在某一类,先怀疑这类目标标注质量;如果错误集中在小目标,提高输入分辨率或增加小目标增强;如果误检集中在管壁纹理区域,按第 4.5 条补负样本。每轮只改一个变量,改完再训练,对比同一组验证图片的变化。

从那以后我每次拿到新的检测数据集,都强制先做一遍格式统计、分布统计、切分自查,再训练一轮并可视化错误样本,走通这条流程才敢把模型往场景里部署。这个习惯帮我少踩了不知道多少坑,也让我对每份资源的真实边界心里有数。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询