简介这份资源面向从事工业质检、缺陷检测算法开发与深度学习实践的研究者和工程师提供管道焊接缝缺陷检测的目标检测数据集采用VOC标注格式的xml文件并已预先完成训练集与测试集划分可直接投入模型训练省去数据清洗与划分的繁琐步骤。压缩包共约2000个文件以1134个xml标注文件、864张jpg图像为主另附1个json类别字典和1个可视化py脚本整体约106.54MB。图像为800×800的RGB图片涵盖good与bad两个类别data目录下分train与test各自包含images与labels子文件夹训练集908张图片及对应xml测试集226张图片及对应xml结构清晰便于直接读取。可视化脚本随机传入一张图片即可绘制边界框并保存到当前目录无需修改即可运行方便快速核验标注质量。目前已有1271人学习下载适合目标检测入门练手、算法对比实验及工业缺陷检测场景的快速验证。1. 管道焊接缝缺陷检测数据集VOC格式的xml标注到底怎么用起来手里拿到一份管道焊接缝缺陷检测数据集VOC标注格式的xml文件已经切好了训练集和测试集——这大概是工业质检方向最常见、也最容易被低估的起点。说它常见是因为VOC从2007年到现在快二十年了几乎每个检测框架都留了它的入口说它容易被低估是因为很多人拿到手第一反应是「转成YOLO txt就完事」结果训练时mAP死活上不去回头查才发现xml里的bndbox坐标越界、类别名大小写不一致、训练集和测试集里同一张图重复出现。管道焊接缝这个场景本身也有它的特殊性缺陷目标细长、对比度低、正负样本极度不均衡VOC的矩形框标注对细长裂纹类缺陷天然不友好。这篇笔记就顺着「拿到一份VOC格式的管道焊接缝缺陷数据集」这条线把目录结构怎么核对、xml怎么解析、训练集测试集怎么验证、转YOLO怎么转、训练时哪些参数必须改一步步讲清楚。适合刚接手工业缺陷检测任务、手里正好是VOC标注的工程师也适合想把这份数据集跑通再决定要不要投入更多标注成本的团队。2. 先看清数据集VOC目录结构与xml字段逐项核对2.1 标准VOC目录长什么样管道焊接缝数据集常见变体一份规范的VOC格式数据集根目录下通常有这几个文件夹Annotations放所有xmlJPEGImages放所有原图ImageSets/Main下放train.txt、val.txt、test.txt这类划分文件。但工业场景里拿到的数据集经常是变体——有的把训练集和测试集直接拆成两个平行目录各自带Annotations和JPEGImages有的把划分信息写在trainval.txt里文件名不带扩展名还有的图片是.bmp或.png而不是.jpg。管道焊接缝数据集因为采集设备可能是工业相机图片格式和命名规则跟自然图像数据集差别很大第一步不是急着写转换脚本而是先把目录结构和文件对应关系摸清楚。我一般会先跑一段统计脚本确认三件事xml数量是否等于图片数量、划分文件里的文件名是否都能在JPEGImages里找到、每个xml里的filename字段是否和实际文件名一致。这三件事任何一件出问题后面训练都会以各种奇怪的方式翻车。import os import xml.etree.ElementTree as ET from collections import Counter root pipe_weld_voc # 数据集根目录 anno_dir os.path.join(root, Annotations) img_dir os.path.join(root, JPEGImages) split_dir os.path.join(root, ImageSets, Main) # 1. xml与图片数量核对 xml_files [f for f in os.listdir(anno_dir) if f.endswith(.xml)] img_files [f for f in os.listdir(img_dir) if f.lower().endswith((.jpg, .png, .bmp))] print(fxml数量: {len(xml_files)}, 图片数量: {len(img_files)}) # 2. 划分文件里的文件名是否都能找到对应图片 for split in [train.txt, val.txt, test.txt]: path os.path.join(split_dir, split) if not os.path.exists(path): print(f{split} 不存在跳过) continue with open(path) as f: names [line.strip().split()[0] for line in f if line.strip()] missing [n for n in names if not os.path.exists(os.path.join(img_dir, n .jpg)) and not os.path.exists(os.path.join(img_dir, n .png))] print(f{split}: {len(names)}条, 缺失图片 {len(missing)}条) # 3. 统计类别分布看管道焊接缝缺陷的类别是否均衡 cls_counter Counter() for xf in xml_files: tree ET.parse(os.path.join(anno_dir, xf)) for obj in tree.getroot().findall(object): cls_counter[obj.find(name).text] 1 print(类别分布:, cls_counter)这段脚本的逻辑很直白先做数量对账再做引用完整性检查最后统计类别分布。参数上唯一需要改的是root路径和图片扩展名元组。如果missing不为空说明划分文件里的文件名和实际图片对不上常见原因是划分文件里带了扩展名而图片目录里没有或者图片是.bmp但脚本只查了.jpg。类别分布那一步尤其关键——管道焊接缝缺陷里「气孔」「夹渣」「裂纹」「未熔合」这几类的样本量往往差一个数量级如果某一类少于50个实例后面训练时要么过采样要么在损失函数里给类别权重否则模型直接学会「全预测成多数类」。2.2 xml里哪些字段必须看哪些可以忽略VOC的xml结构不复杂根节点annotation下面有folder、filename、path、source、size、segmented、object这些子节点。真正影响训练的是size里的width和height以及每个object里的name、pose、truncated、difficult、bndbox。folder和path基本可以忽略很多标注工具导出的path还是标注机器上的绝对路径换台机器就没意义了。bndbox里的xmin、ymin、xmax、ymax是重点核对对象。工业数据集常见的问题有三个坐标是浮点数而不是整数有些标注工具会输出xmin: 123.45坐标超出图片宽高标注时图片被缩放但坐标没同步以及xmin xmax这种低级错误。这三种情况在转YOLO格式时都会导致归一化后坐标越界训练时表现为loss突然变NaN或者某些框完全学不到。def check_bbox(anno_dir, img_dir): issues [] for xf in os.listdir(anno_dir): if not xf.endswith(.xml): continue tree ET.parse(os.path.join(anno_dir, xf)) root tree.getroot() size root.find(size) w, h int(size.find(width).text), int(size.find(height).text) for obj in root.findall(object): bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) if xmin xmax or ymin ymax: issues.append((xf, 坐标反转)) if xmin 0 or ymin 0 or xmax w or ymax h: issues.append((xf, f越界: ({xmin},{ymin},{xmax},{ymax}) vs ({w},{h}))) if any(v ! int(v) for v in [xmin, ymin, xmax, ymax]): issues.append((xf, 浮点坐标)) return issues issues check_bbox(anno_dir, img_dir) for item in issues[:20]: print(item) print(f共发现 {len(issues)} 处问题)这段检查脚本返回的issues列表里每一条是一个(文件名, 问题类型)元组。实际处理时坐标反转和越界必须修浮点坐标可以四舍五入。修的方式我一般是在转换脚本里直接做clipxmin max(0, min(xmin, w-1))xmax max(xmin1, min(xmax, w))。注意difficult字段——VOC里标为difficult1的目标在评估时通常会被忽略但训练时是否忽略取决于你的框架配置。管道焊接缝数据集里如果有些缺陷特别模糊、标注员自己都拿不准标了difficult1训练时建议保留但评估时忽略这样模型至少能学到一些边缘特征。3. 训练集测试集划分验证别让数据泄漏毁掉评估结果3.1 划分文件怎么读train/val/test三者关系VOC的ImageSets/Main下常见四个文件train.txt、val.txt、trainval.txt、test.txt。trainval是train和val的并集test通常独立。但管道焊接缝数据集因为已经「做了训练集和测试集划分」可能只给了train.txt和test.txt没有单独的val。这种情况下我一般从train里再切10%到15%做验证集切的时候按类别分层抽样保证每个缺陷类别在验证集里都有足够实例。如果直接随机切很可能某个稀有缺陷类别在验证集里一个都没有评估时那一类的AP直接是0你还以为是模型没学好。分层抽样的逻辑不复杂先统计每个类别的实例分布在哪些图片上然后按类别把图片分组每组按比例抽到验证集。一张图可能包含多个类别所以实际做的时候是按图片的类别组合来分层或者简单点按图片里出现的最稀有类别来分层。import random from collections import defaultdict def stratified_split(anno_dir, train_txt, val_ratio0.15, seed42): random.seed(seed) # 统计每张图的类别集合 img_classes {} for line in open(train_txt): name line.strip().split()[0] xml_path os.path.join(anno_dir, name .xml) if not os.path.exists(xml_path): continue tree ET.parse(xml_path) classes set(obj.find(name).text for obj in tree.getroot().findall(object)) img_classes[name] classes # 按最稀有类别分组 cls_to_imgs defaultdict(list) for name, classes in img_classes.items(): rarest min(classes, keylambda c: sum(1 for s in img_classes.values() if c in s)) cls_to_imgs[rarest].append(name) train_names, val_names [], [] for cls, names in cls_to_imgs.items(): random.shuffle(names) n_val max(1, int(len(names) * val_ratio)) val_names.extend(names[:n_val]) train_names.extend(names[n_val:]) return train_names, val_names train_names, val_names stratified_split(anno_dir, os.path.join(split_dir, train.txt)) print(f训练集 {len(train_names)} 张, 验证集 {len(val_names)} 张)这里rarest的计算是O(N*C)的数据集不大时完全够用。val_ratio设0.15是个经验值数据量少于500张时建议设0.2保证验证集里每类至少有10到20个实例。seed固定是为了可复现工业项目里评估结果要能对得上随机种子不固定后面没法复现。3.2 数据泄漏排查训练集和测试集有没有重复图片这是血泪经验里最常见的一条数据集提供方切分时按文件名随机切但同一张原图可能被不同文件名保存了两次或者同一段焊缝的连续帧被分别切到了训练集和测试集。管道焊接缝检测里连续采集的视频帧之间差异极小如果训练集里有第100帧、测试集里有第101帧模型在测试集上的表现会虚高实际部署时换一段焊缝就崩。排查方法有两种一是算图片的感知哈希pHash二是算图片的MD5。MD5只能查完全相同的文件pHash能查视觉上几乎一样的图。工业场景里我一般两个都跑MD5快pHash兜底。import hashlib from PIL import Image import imagehash def md5_of_file(path): h hashlib.md5() with open(path, rb) as f: for chunk in iter(lambda: f.read(8192), b): h.update(chunk) return h.hexdigest() def phash_of_file(path): return str(imagehash.phash(Image.open(path))) def find_leakage(train_names, test_names, img_dir, hash_typephash, threshold5): train_hashes {} for n in train_names: for ext in [.jpg, .png, .bmp]: p os.path.join(img_dir, n ext) if os.path.exists(p): train_hashes[n] phash_of_file(p) if hash_type phash else md5_of_file(p) break leaks [] for n in test_names: for ext in [.jpg, .png, .bmp]: p os.path.join(img_dir, n ext) if os.path.exists(p): h phash_of_file(p) if hash_type phash else md5_of_file(p) if hash_type md5: if h in train_hashes.values(): leaks.append(n) else: for tn, th in train_hashes.items(): if imagehash.hex_to_hash(h) - imagehash.hex_to_hash(th) threshold: leaks.append((n, tn)) break break return leaks leaks find_leakage(train_names, test_names, img_dir, hash_typephash, threshold5) print(f疑似泄漏 {len(leaks)} 对)threshold5是pHash汉明距离的经验阈值小于等于5基本可以认为是同一张图或几乎相同的帧。发现泄漏后处理方式是把测试集里泄漏的图移到训练集或者直接从测试集剔除。如果泄漏比例超过5%这份数据集的划分就需要重新做不能直接用。4. 转YOLO格式xml解析、坐标归一化与类别映射4.1 xml转YOLO txt的完整脚本与四个边界坑YOLO格式的标注是每张图一个txt每行class_id x_center y_center width height全部归一化到0到1。转换本身不难难的是边界处理。四个最常见的坑坐标越界没clip导致归一化后大于1类别名到id的映射在训练集和测试集之间不一致图片宽高从xml的size读而不是从实际图片读两者不一致时坐标全错空标注图片没有目标生成空txt有些训练框架会把空txt当成负样本有些直接报错。import os import xml.etree.ElementTree as ET from PIL import Image def voc_to_yolo(anno_dir, img_dir, out_dir, class_map, use_actual_sizeTrue): os.makedirs(out_dir, exist_okTrue) for xf in os.listdir(anno_dir): if not xf.endswith(.xml): continue tree ET.parse(os.path.join(anno_dir, xf)) root tree.getroot() # 优先用实际图片尺寸避免xml里size字段不准 img_name root.find(filename).text img_path None for ext in [.jpg, .png, .bmp]: p os.path.join(img_dir, os.path.splitext(img_name)[0] ext) if os.path.exists(p): img_path p break if img_path is None: print(f找不到图片: {img_name}) continue if use_actual_size: w, h Image.open(img_path).size else: size root.find(size) w, h int(size.find(width).text), int(size.find(height).text) lines [] for obj in root.findall(object): cls_name obj.find(name).text.strip() if cls_name not in class_map: print(f未知类别 {cls_name} in {xf}) continue bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) # clip到图片范围内 xmin max(0, min(xmin, w - 1)) ymin max(0, min(ymin, h - 1)) xmax max(xmin 1, min(xmax, w)) ymax max(ymin 1, min(ymax, h)) # 归一化 xc (xmin xmax) / 2.0 / w yc (ymin ymax) / 2.0 / h bw (xmax - xmin) / w bh (ymax - ymin) / h lines.append(f{class_map[cls_name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}) out_name os.path.splitext(xf)[0] .txt with open(os.path.join(out_dir, out_name), w) as f: f.write(\n.join(lines)) class_map {crack: 0, porosity: 1, slag: 2, lack_of_fusion: 3} voc_to_yolo(anno_dir, img_dir, labels, class_map, use_actual_sizeTrue)use_actual_sizeTrue是我强烈建议的默认值。工业数据集里xml的size字段经常是标注工具按缩放后的预览图写的跟原图尺寸对不上用size算出来的归一化坐标全是错的。class_map必须全局唯一训练集和测试集用同一份不能训练集里crack是0、测试集里crack变成1。空标注图片会生成空txtYOLOv8默认会把空txt当负样本这是合理的但如果你的数据集里空图特别多比如超过30%需要控制负样本比例否则模型偏向于预测背景。4.2 类别名清洗与id映射的工程化做法管道焊接缝缺陷的类别名在不同数据集里写法五花八门crack、Crack、cracks、裂纹、crack_defect。如果直接按原始字符串建映射很容易出现同一类缺陷被拆成多个id。我一般先跑一遍全量xml的类别名统计人工确认哪些是同一类然后写一个清洗函数统一成小写下划线格式。def collect_class_names(anno_dir): names Counter() for xf in os.listdir(anno_dir): if not xf.endswith(.xml): continue tree ET.parse(os.path.join(anno_dir, xf)) for obj in tree.getroot().findall(object): names[obj.find(name).text.strip()] 1 return names raw_names collect_class_names(anno_dir) for name, cnt in raw_names.most_common(): print(f{name}: {cnt}) # 人工确认后写清洗规则 alias { Crack: crack, cracks: crack, 裂纹: crack, Porosity: porosity, 气孔: porosity, Slag: slag, 夹渣: slag, Lack_of_Fusion: lack_of_fusion, 未熔合: lack_of_fusion, } def clean_name(n): n n.strip() return alias.get(n, n.lower().replace( , _))清洗规则里的alias字典需要根据实际统计结果来写不能照搬。统计完如果发现某个类别实例数少于总实例数的1%比如总共5000个框里某一类只有30个要么合并到相近类别要么在训练时用重采样。管道焊接缝检测里「裂纹」和「未熔合」在视觉上有时很难区分如果标注一致性不高可以考虑合并成一个大类减少模型困惑。5. 训练配置与避坑从VOC数据集到可用的检测模型5.1 YOLOv8训练管道焊接缝数据集的必调参数转完格式后目录结构一般是images/train、images/val、labels/train、labels/val再加一个data.yaml。data.yaml里写path、train、val、names。训练命令本身不复杂但管道焊接缝场景有几个参数必须调。# data.yaml path: ./pipe_weld_yolo train: images/train val: images/val names: 0: crack 1: porosity 2: slag 3: lack_of_fusionyolo detect train \ datadata.yaml \ modelyolov8s.pt \ epochs200 \ imgsz1024 \ batch8 \ lr00.001 \ lrf0.01 \ mosaic0.5 \ mixup0.0 \ copy_paste0.0 \ degrees180.0 \ fliplr0.5 \ flipud0.5 \ patience50 \ save_period20imgsz1024是因为管道焊接缝缺陷目标通常很小640的输入下细长裂纹可能只剩几个像素。degrees180.0和flipud0.5是工业缺陷检测的特殊之处——焊缝图片旋转180度、上下翻转后缺陷的语义不变这些增强能显著提升泛化。mosaic0.5比默认的1.0低因为mosaic把四张图拼一起对小目标密集的场景可能引入太多背景噪声。lr00.001比默认的0.01低一个数量级工业数据集通常不大学习率太高容易震荡。patience50配合save_period20方便早停后回看中间checkpoint。5.2 训练过程中的排查清单训练启动后第一件事是看labels.jpg和labels_correlogram.jpg确认框的分布是否合理。如果labels.jpg里框全挤在图片中心说明归一化有问题如果某些类别的框数量明显偏少回到第2章的类别统计去确认。第二件事是看第一个epoch的lossbox_loss如果一开始就很大然后迅速降为0大概率是标注有问题模型直接学会了预测固定框。第三件事是看验证集的mAP曲线如果训练集mAP一直涨但验证集mAP从第20个epoch开始跌说明过拟合需要加增强或减模型容量。提示管道焊接缝数据集的验证集如果只有几十张图mAP的波动会很大建议看连续5个epoch的移动平均不要被单个epoch的跳变带偏。6. 避坑与常见问题VOC管道焊接缝数据集最容易翻车的五个地方6.1 现象训练loss正常但mAP一直是0原因通常是类别id映射错了。YOLO的data.yaml里names的顺序必须和生成txt时的class_map完全一致如果txt里crack是0、porosity是1但data.yaml里写反了模型学到的类别和评估时对不上mAP直接是0。解决方法是拿一张训练图手动核对txt里的class_id和data.yaml里的names确认一一对应。6.2 现象验证集mAP比训练集高很多这几乎可以确定是数据泄漏。训练集和验证集里有重复或高度相似的图片模型在验证集上「见过」这些图。回到3.2节的pHash排查把泄漏的图从验证集剔除或移到训练集。管道焊接缝连续帧场景下这个问题特别普遍不要跳过这一步。6.3 现象模型对细长裂纹检测效果差框总是偏短VOC的矩形框对细长目标天然不友好标注时裂纹的两端很难标准导致bndbox比实际裂纹短一截。解决方式有两个一是训练时用copy_paste增强把裂纹实例复制粘贴到其他位置增加样本多样性二是评估时用更宽松的IoU阈值比如0.3而不是0.5看模型是否至少能定位到裂纹区域。如果业务上允许可以考虑把检测任务改成分割任务用多边形标注替代矩形框。6.4 现象转YOLO后图片和标签对不上训练时提示找不到label原因是图片文件名和txt文件名不一致。VOC的xml里filename字段可能带扩展名而YOLO要求txt文件名和图片文件名不含扩展名完全一致。转换脚本里生成txt时用的是xml文件名但图片可能是另一个名字。解决方法是统一以图片文件名为准生成txtxml里的filename只用来找图片不用来命名txt。6.5 现象训练到一半loss突然变NaN最常见的原因是某个batch里出现了宽或高为0的框。VOC转YOLO时如果xmin xmax或ymin ymax归一化后bw或bh为0YOLO的损失计算里会除零。回到4.1节的clip逻辑确保xmax xmin 1、ymax ymin 1。另外检查是否有图片尺寸为0的损坏文件用PIL.Image.open打开所有图片验证一遍。7. 进阶技巧用VOC的difficult字段做课程学习VOC的difficult字段在标准评估里是被忽略的但在训练时可以拿来做课程学习。思路很简单第一阶段的epoch只用difficult0的样本训练让模型先学好清晰缺陷第二阶段把difficult1的样本也加进来用更低的学习率微调。管道焊接缝数据集里那些模糊、遮挡、边界不清的缺陷标注员标了difficult1直接混在一起训练会让模型在早期就被难样本带偏。实现上在转YOLO格式时额外生成一份difficult标记文件或者在txt里用第五个字段标记YOLO格式本身支持额外的字段但多数框架忽略。更干净的做法是生成两个版本的labels目录labels_easy只含difficult0的框labels_all含全部框。第一阶段用labels_easy训练第二阶段切换到labels_all学习率降到第一阶段的十分之一。def voc_to_yolo_with_difficult(anno_dir, img_dir, out_easy, out_all, class_map): os.makedirs(out_easy, exist_okTrue) os.makedirs(out_all, exist_okTrue) for xf in os.listdir(anno_dir): if not xf.endswith(.xml): continue tree ET.parse(os.path.join(anno_dir, xf)) root tree.getroot() img_name root.find(filename).text img_path None for ext in [.jpg, .png, .bmp]: p os.path.join(img_dir, os.path.splitext(img_name)[0] ext) if os.path.exists(p): img_path p break if img_path is None: continue w, h Image.open(img_path).size easy_lines, all_lines [], [] for obj in root.findall(object): cls_name obj.find(name).text.strip() if cls_name not in class_map: continue bbox obj.find(bndbox) xmin max(0, min(float(bbox.find(xmin).text), w - 1)) ymin max(0, min(float(bbox.find(ymin).text), h - 1)) xmax max(xmin 1, min(float(bbox.find(xmax).text), w)) ymax max(ymin 1, min(float(bbox.find(ymax).text), h)) xc (xmin xmax) / 2.0 / w yc (ymin ymax) / 2.0 / h bw (xmax - xmin) / w bh (ymax - ymin) / h line f{class_map[cls_name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f} all_lines.append(line) difficult obj.find(difficult) if difficult is None or int(difficult.text) 0: easy_lines.append(line) out_name os.path.splitext(xf)[0] .txt with open(os.path.join(out_easy, out_name), w) as f: f.write(\n.join(easy_lines)) with open(os.path.join(out_all, out_name), w) as f: f.write(\n.join(all_lines))两阶段训练的切换点怎么定我一般看第一阶段验证集mAP连续10个epoch不涨了就切到第二阶段。第二阶段的学习率设第一阶段的0.1倍epoch数设第一阶段的30%到50%。这个技巧在管道焊接缝数据集上通常能带来2到4个点的mAP提升尤其是那些难样本占比超过20%的数据集。代价是训练时间翻倍所以如果数据集本身很干净、difficult1的样本很少不值得做。我自己做工业缺陷检测这些年最大的习惯是拿到任何VOC数据集先跑一遍第2章和第3章的检查脚本哪怕提供方说「已经清洗好了」。管道焊接缝这个场景尤其如此标注质量参差不齐是常态花半小时做数据体检比训练三天后回头查问题划算得多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?