水下垃圾检测数据集全解析:7类5328张三种标签格式与YOLO训练实战 简介这是基于水下机器人实拍影像构建的7类水下垃圾检测数据集共5328张标注图片覆盖水下生物、塑料、金属、木材、橡胶、布料及捕鱼工具等类别适合用于水域环境监测、水下智能巡检及目标检测算法训练。包内文件总数21313个包含5328张JPG原图及对应的XML、TXT、JSON三种格式标签可无缝适配VOC、YOLO及主流检测框架省去格式转换环节。整体压缩包127.54MB图片与标签按编号一一对应数据分布较均衡。目前已有325人学习使用适合课程设计、毕业设计、竞赛训练或真实水下项目预研。对于需要快速搭建水下目标检测任务的开发者可直接划分训练集投入模型训练并利用多格式标签灵活切换工具链。1. 水下垃圾检测数据集5328张图的7类标注三种标签格式怎么选、怎么用做水下垃圾检测模型选谁大家心里都有底真正卡脖子的永远是数据集。你搜到的开源目标检测数据要么是COCO这种通用场景对水下的瓶子、渔网、塑料碎片覆盖极少要么就是一个压缩包下来只有图片没有标签整个周末耗在标注上。这个数据集标题里最大的价值就在这水下垃圾检测数据集7类-5328张-含voc(xml)yolo(txt)json三种格式标签.zip图片量不算大但7类目标的标签一次给齐了三种格式覆盖了VOC生态、YOLO生态和COCO风格json生态的对接需求。对刚入门yolo学习的工程师它是最合适的练手底料有标注、有多类、有现成格式直接按步骤整理就能训练对已经在做水下水产、河道垃圾清理项目的人它可以作为迁移学习的起点模型数据不必再从零标几百张图。下面按「标签格式解剖 → 目录整理与训练 → 踩坑排查 → 验证与进阶」展开每一段都能照着做。2. 三种标签格式的解剖VOC的xml、YOLO的txt、JSON的结构差异拿到压缩包第一件事先别急着解压后丢给训练脚本。把三种格式各自的用途边界搞清楚才知道在什么环节该用哪一份也才能在训练中途报错时快速判断是数据问题还是模型问题。2.1 VOC的xml怎么读annotation节点、object层级与xml解析VOC格式是目标检测里最老派也最稳定的框表达方式。它的特点是“人眼友好”一个xml文件对应一张图片根节点是annotation里面有size宽高、通道数、segmented是否有更细粒度的分割标注以及若干个object节点每个object里的bndbox给出左上角和右下角两个点的绝对像素坐标。xml文件用什么打开和编辑都很随意VS Code、Notepad、浏览器都能看但要批量改几千个xml里的类别名或坐标脚本才兜得住。我这里用python的xml.etree.ElementTree做xml解析不依赖第三方库import xml.etree.ElementTree as ET from pathlib import Path xml_path Path(xml/000001.xml) # VOC标签目录里的单个文件 tree ET.parse(xml_path) root tree.getroot() img_w int(root.find(size/width).text) # 原始图像宽 img_h int(root.find(size/height).text) # 原始图像高 boxes [] for obj in root.iter(object): name obj.find(name).text # 类别名如 bottle bndbox obj.find(bndbox) xmin int(bndbox.find(xmin).text) # 左上角 x绝对像素 ymin int(bndbox.find(ymin).text) xmax int(bndbox.find(xmax).text) # 右下角 x绝对像素 ymax int(bndbox.find(ymax).text) boxes.append({name: name, xmin: xmin, ymin: ymin, xmax: xmax, ymax: ymax}) print(f图像尺寸: {img_w}x{img_h}, 目标数量: {len(boxes)}) print(boxes)这段代码的核心逻辑是把一个“人读的标签”转成“机器好处理的列表”。用root.find(size/width)而不是root.findall()是因为每个xml里只有一个size节点find直接取第一个匹配项就够了root.iter(object)遍历所有嵌套的object即使节点层级里多包了一层也能拿到比逐层findall更稳。参数说明VOC的bndbox里存的是坐标边界不是中心点加宽高如果你在用LabelImg标注导出的就是这个格式。数值必须int()xml里读出来是字符串直接参与计算会出类型错误。xml解析对编码敏感建议统一用utf-8Windows上如果看到中文乱码多半是文件本身存成了gbk先用文本编辑器另存为utf-8再接ET.parse。2.2 YOLO的txt归一化坐标为什么是非整数YOLO的txt标签每一行对应一个目标格式是“类别id 中心点x 中心点y 宽 高”五个值全部按图像宽高归一化范围0到1。这也解释了为什么你在YOLO标注文件里看到一堆0.几的小数而不是“320,241”这种整数。归一化的好处是模型在不同输入分辨率下都能复用同一份标签不用每换一次输入尺寸就改一遍标注。with open(yolo/000001.txt, r, encodingutf-8) as f: for idx, line in enumerate(f): parts line.strip().split() if len(parts) ! 5: print(f第{idx1}行格式异常: {line}) continue cls_id, cx, cy, bw, bh map(float, parts) # 中心点坐标换算回绝对像素便于和XML交叉检查 center_x_abs int(cx * img_w) center_y_abs int(cy * img_h) w_abs int(bw * img_w) h_abs int(bh * img_h) print(f类别{cls_id} 中心({center_x_abs},{center_y_abs}) 宽{w_abs} 高{h_abs})注释里反复强调“和XML交叉检查”同一个目标在txt里是归一化数值在xml里是绝对像素把txt反算回绝对像素再和xml比对能快速验证两个标注文件是不是对应同一目标。实际操作里我一般会抽3到5张图做这种交叉检查如果同一目标在两个文件里算出的像素坐标差超过3个像素就要怀疑数据集在生成时用的原图和当前目录里的图分辨率不一致。参数说明map(float, parts)是整行解析的关键类别id虽然应该是整数但统一用float解析再处理比直接int(parts[0])更不容易因为“1.0”这种写法崩掉。空行跳过比报错更稳妥很多标注工具会在文件末尾留一个空行这不影响YOLO读取但会影响你按行检查时的索引。2.3 JSON格式标签COCO风格的annotations数组与json查询函数JSON标签常见有两种形态一种是每张图片一个json文件另一种是COCO风格的总json内含images和annotations两个大数组。这类数据集给出json格式绝大多数是为了方便对接COCO API、Detectron2这类需要结构化注释的工具。json的层级和数组可以用json查询函数直接定位比如键索引、数组遍历不需要像xml那样一层一层find。import json with open(annotations/instances.json, r, encodingutf-8) as f: anns json.load(f) img_id_to_path {img[id]: img[file_name] for img in anns[images]} cat_id_to_name {cat[id]: cat[name] for cat in anns[categories]} records [] for ann in anns[annotations]: x, y, w, h ann[bbox] # coco bbox是左上角 宽高 records.append({ image: img_id_to_path[ann[image_id]], category: cat_id_to_name[ann[category_id]], bbox: [int(x), int(y), int(w), int(h)], area: ann.get(area), segmentation: ann.get(segmentation) # 多边形/掩码信息 }) print(records[:5])注意COCO的bbox定义和VOC完全不一样COCO是“左上角x左上角y宽高”VOC是“左上角x左上角y右下角x右下角y”互转时要么减要么加别拿错。json数组里的元素可以用下标取也可以按key过滤这种json查询函数只要记住“先取字典再取key”的写法不要在长的链式调用上翻车就行。我特意提一下segmentation字段如果它是polygon格式的列表说明这份json可以做实例分割的底料如果segmentation是空数组那它只是检测框。这个字段决定了你后期能不能升级成yolo实例分割任务后面第5章还会用到这个判断。2.4 三种格式互转VOC的xml到YOLO的txt公式与脚本理解了三种格式之后互转脚本几乎必写一遍。即使数据本身三种都给齐你也可能在三个目录里发现某个xml和txt对不上目标数。自己写一次转换脚本能在过程中发现这类不一致比事后怀疑模型可靠得多。def voc_to_yolo(xmin, ymin, xmax, ymax, img_w, img_h): VOC绝对坐标转YOLO归一化坐标的完整公式 x_center ((xmin xmax) / 2.0) / img_w y_center ((ymin ymax) / 2.0) / img_h box_w (xmax - xmin) / img_w box_h (ymax - ymin) / img_h # 防止浮点误差导致越界 x_center max(0.0, min(1.0, x_center)) y_center max(0.0, min(1.0, y_center)) return x_center, y_center, box_w, box_h def yolo_to_voc(cx, cy, bw, bh, img_w, img_h): YOLO归一化坐标转VOC绝对坐标的完整公式 xmin int((cx - bw / 2.0) * img_w) ymin int((cy - bh / 2.0) * img_h) xmax int((cx bw / 2.0) * img_w) ymax int((cy bh / 2.0) * img_h) return xmin, ymin, xmax, ymax逻辑说明VOC转YOLO的核心是求中心点再除以宽高YOLO转VOC是反过来用归一化中心点减去一半宽高得到左上角。最后那行max/min裁剪很重要四舍五入可能把值推到1.0000001YOLO训练时一旦出现超出[0,1]的坐标边界框回归的损失函数会算出无效值loss显示成nan。顺便说一个常见困惑VOC的xmax是右下角的像素索引还是边界框的宽度主流标注工具里它表示右下角坐标所以框宽是xmax-xmin而不是xmax-xmin1。这个1对最终mAP影响很小但批量转换时要全量统一否则同一张图在不同格式间来回转边界会越差越多。如果你以前只接触过COCO 80类的预训练权重这里尤其要注意这个数据集是7类不能直接套coco80的类别名去读txtid从0到6和COCO的id顺序完全无关。3. 把数据集整理成YOLOv8能直接训练的目录结构划分、yaml配置、标签自检标签格式看得懂不等于train.py能跑起来。很多新手把下载的数据直接丢进yolo的data文件夹报错说“no labels found”时一脸懵。YOLO系列对目录结构有强约定图片和标签要分两个根目录且图片名和标签名不含扩展名必须完全一致。这一章按YOLOv8ultralytics训练自己的数据集的常见做法把目录、配置文件、自检脚本一次讲清。3.1 目录结构设计image与label分开放train/val/test按比例划分训练前先把数据固定成一个稳定结构推荐这样排datasets/underwater/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ └── labels/ ├── train/ ├── val/ └── test/对应的每个train/images/xxx.jpg的标签必须在train/labels/xxx.txt里。划分比例我用最常见的8:1:15328张图大约是4262/533/533。下面的脚本按文件stem不含扩展名的文件名把图片和标签一起搬进对应子目录import random, shutil from pathlib import Path src_img Path(src_data/images) src_lab Path(src_data/labels) img_files sorted(src_img.glob(*.jpg)) random.seed(42) random.shuffle(img_files) total len(img_files) train_imgs img_files[:int(total * 0.8)] val_imgs img_files[int(total * 0.8):int(total * 0.9)] test_imgs img_files[int(total * 0.9):] def copy_group(img_list, split_name): img_dst Path(fdatasets/underwater/images/{split_name}) lab_dst Path(fdatasets/underwater/labels/{split_name}) img_dst.mkdir(parentsTrue, exist_okTrue) lab_dst.mkdir(parentsTrue, exist_okTrue) for img in img_list: shutil.copy(img, img_dst / img.name) label src_lab / f{img.stem}.txt if label.exists(): shutil.copy(label, lab_dst / label.name) else: print(f警告{img.name} 没有对应标签txt) copy_group(train_imgs, train) copy_group(val_imgs, val) copy_group(test_imgs, test)复制之前先做一次shuffle而且固定随机种子。原因很实在水下垃圾图片里连续帧相似度高如果直接按文件名排序切分很可能前50张全是同一片水域的同一堆垃圾训练集和验证集互相泄漏val mAP虚高换数据立刻打回原形。固定random.seed(42)别人复现你的实验时划分逻辑也能对得上。参数说明这里用Path.glob(.jpg)如果压缩包里是png或jpeg把后缀换成对应扩展名或者干脆glob(.j*)。找标签时不建议用字符串replace(images, labels)因为一旦目录命名不规范就出错统一改成src_lab / f{img.stem}.txt通过stem精确对接最稳。3.2 data.yaml配置nc、names、path三个参数一个都不能错YOLOv8训练时读取的data.yaml是数据集的契约文件。路径写错、类别数写错、类别名写错训练要么直接报错要么mAP全是0还不报错。我给这套水下垃圾数据集写的yaml长这样# datasets/underwater/data.yaml path: /absolute/path/to/datasets/underwater # 数据集的绝对根目录 train: images/train # 相对path的文件夹 val: images/val test: images/test # 测试集训练时用不到但可以留着 # 7个类别以下名称只是示例务必以你解压后的xml/txt里实际name为准 nc: 7 names: 0: plastic_bag 1: bottle 2: fishing_net 3: metal_can 4: glass_bottle 5: rubber 6: other唯一要特别小心的是path这一行。写成绝对路径可以避免从不同目录启动训练时找不到图片如果你有多台机器同步训练数据用相对路径配合软链接也可以但新手阶段直接写绝对路径最省事。nc和names的数量必须对应txt第一列出现过的最大id再加1比如txt里最大类别id是6nc就是7写成6的结果是越界报错错误信息还经常藏在一堆日志里不好定位。提示data.yaml里的names顺序一旦确定就不要在训练中途改。YOLO的类别id是按names列表下标取的顺序变了等于所有txt的类别都变了。3.3 标签自检写一个批量检查脚本跑训练前先排除明显错误数据整理完、yaml写好后我习惯先跑一个标签自检脚本而不是直接开大训练。这个脚本能在几十秒内把“缺图、缺标签、格式行数不对、坐标越界、类别id越界”一次查完from pathlib import Path img_dir Path(datasets/underwater/images/train) lab_dir Path(datasets/underwater/labels/train) nc_expected 7 img_suffixes {.jpg, .jpeg, .png} images {p.stem: p for p in img_dir.iterdir() if p.suffix.lower() in img_suffixes} for txt in lab_dir.glob(*.txt): if txt.stem not in images: print(f[缺图] 标签 {txt.name} 没有对应图片) continue lines txt.read_text(encodingutf-8).strip().splitlines() if not lines: print(f[空标签] {txt.name}) continue for i, line in enumerate(lines): parts line.strip().split() if len(parts) ! 5: print(f[列数错误] {txt.name} 第{i1}行: {line}) continue cls_id, cx, cy, bw, bh map(float, parts) if cls_id 0 or cls_id nc_expected: print(f[类别越界] {txt.name} 第{i1}行 cls{cls_id}) if cx 0 or cx 1 or cy 0 or cy 1 or bw 0 or bh 0: print(f[坐标异常] {txt.name} 第{i1}行 : {cx},{cy},{bw},{bh}) # 反向查找缺标签的图片 for stem, img in images.items(): if not (lab_dir / f{stem}.txt).exists(): print(f[缺标签] 图片 {img.name} 没有对应txt)逻辑说明脚本做了四件事——标签与图片是否存在配对、每一行是不是5个字段、类别id是否在0到6之间、归一化坐标是否在合法区间。“空标签”那种情况很常见且隐蔽标注时漏了一张txt是0字节文件YOLO不会报错但训练时会产生大量背景误检val mAP被拖低。参数说明nc_expected要与data.yaml里的nc对齐。如果你想查得更细可以在脚本里用PIL读图片真实尺寸再验证每个目标的宽高是否明显大于0且不超过图像尺寸。实际跑一遍大部分错误都集中在“列数不对”和“缺标签”纠完再训练基本不闹脾气。3.4 启动训练epochs、batch、imgsz怎么设目录和yaml就绪后训练命令很短。我一般用ultralytics提供的CLI直接起训练yolo detect train datadatasets/underwater/data.yaml \ modelyolov8s.pt \ epochs100 \ batch16 \ imgsz640 \ seed42 \ projectruns_train \ nameunderwater_baseline参数说明modelyolov8s.pt表示用yolov8s的预训练权重做起点水下垃圾和COCO的通用特征有部分重叠迁移学习比从零训练收敛快得多。epochs100是起步值5328张图对单卡来说100轮大概2到4小时看显卡而定。imgsz640是精度和速度的平衡点水下小目标多如果显存够试试imgsz1280会明显提升小目标召回但训练时间接近翻倍。训练日志里重点关注三个值box_loss、cls_loss、dfl_loss。box_loss稳定下降说明定位在学cls_loss下降说明分类在学dfl_loss是分布损失波动大没关系但持续上升就要检查标签质量。这三项对应着yolo损失函数的三个组成部分之前自检脚本没跑干净的话这里就会出幺蛾子。4. 水下垃圾数据的5个常见坑训练不收敛、损失nan、mAP虚高的排查清单水下垃圾数据集相比常规目标检测数据有几个特别容易踩的坑。下面按“现象→原因→解决”记录都是实际跑yolo系列时反复踩过的。4.1 训练第一轮就报IndexError类别id和nc对不上现象数据加载到一半就断了错误信息是IndexError: index X is out of range for axis 0 with size 7。 原因txt里某一行的类别id是7或者更大但data.yaml里nc配的是7或者raw数据里有8个类别生成标签时漏了一层过滤多余类别混了进来。 解决先跑3.3的自检脚本再用一条命令统计全量标签的类别分布cat datasets/underwater/labels/train/*.txt | awk {print $1} | sort | uniq -c这条awk命令会输出每个类别id出现的次数。看到最大值是6就说明类别id没问题如果出现7甚至更大用sed做全量id修正把id7并到id6sed -i s/^7 /6 / datasets/underwater/labels/train/*.txt datasets/underwater/labels/val/*.txt修正完重新跑一遍自检。原因是全量数据里出现了原始类标的噪声常见是标注员把同一个目标标成了两个相近类别名合类比重新标注省力得多。4.2 训练两个epoch后loss变成nan框宽为0或中心坐标越界现象loss曲线第一个epoch正常下降第二个epoch突然变成nan之后无法恢复。 原因某个txt里bw或bh是0或者中心坐标cx写成1.0以上YOLO损失函数在做边界框回归时算出了无效值反向传播直接把权重冲坏了。 解决自检脚本里保留bw 0和bh 0的判断就是为抓这种问题。命中后直接删掉那一行或者把该目标过滤出训练集。如果越界值是1.0000001这种浮点误差用awk裁剪awk {if($21)$21; if($31)$31; print} file.txt tmp mv tmp file.txt这个命令把第二列第三列超过1的值拉回1再原地替换文件。注意awk会把原文件格式重排一遍操作前先备份。4.3 水下图像整体偏蓝绿色训练loss不下降现象loss一直在0.5上下抖动训练了上百个epoch也不见明显下降val mAP一直上不去。 原因水下垃圾图片的颜色分布和常规图片差异太大整张图普遍偏蓝绿色RGB三个通道信息量不如常规数据丰富。如果直接套用YOLOv8默认的马赛克和色彩增强部分增强反而把原本就低对比度的目标变得更难识别。 解决训练参数里把hsv_h、hsv_s、hsv_v的增强幅度调小把mosaic概率降到0.3左右让模型多看原始水下画面少看人工拼接后的画面。具体做法是在训练命令里追加yolo detect train ... \ hsv_h0.02 hsv_s0.4 hsv_v0.3 \ mosaic0.3这类参数改动对水下数据集经常有奇效代价是鲁棒性略降但mAP能上去。4.4 7个类别样本量差异巨大少数类mAP为0现象训练结束前几个大类mAP在0.6以上但像鱼线、渔网这种细长目标mAP是0。 原因数据不平衡少数类可能只有几十个标注框细长目标本身在yolo的方形锚框体系里不占优势正样本太少模型直接忽略它们。 解决要么用类权重要么做复制粘贴增广。我一般把少数类在训练集中重复几遍设cls权重偏置到少数类# 统计类别频率后把低频类权重调高例如2.0 weights [1.0, 1.0, 2.5, 1.5, 2.0, 2.0, 3.0]更直接的做法是把少数类目标的小图裁出来贴到其他背景图上扩充样本数。对鱼线这种完全线形的目标认真考虑转实例分割可能比调anchor更有效但不要指望纯角框yolo有很好的表现。4.5 val集划分不固定同一次训练两次验证结果差3个点现象没改任何参数重启一次训练val mAP从0.72变成0.69。 原因训练时没固定随机种子val集在dataloader里被shuffle了两次或者val集里漏标严重随机掉几张小目标图mAP就往下跳。 解决固定seed在训练命令里加seed42然后把val集固定下来用自检脚本检查val里有没有空标签txt。如果val只有几十张图改成95:5或者增加val集到800张指标才稳定。val集越小统计噪声越大这个波动不是模型退化是评测本身不稳定。注意固定随机种子的方法不只用于切分数据集训练阶段也要传seed42否则同样的yaml跑两次指标仍然不一样。4.6 文件名带空格或中文图片加载成功标签对不上现象训练时提示image 1/xxx corrupt JPEG或者有些图片直接跳过实际训练量比预期少。 原因文件名里带空格、括号或中文时路径拼接的偶然性变大更麻烦的是标签名和图片名带着同样的特殊字符一个转义一个不转义就会出现“图片存在但label找不到”。 解决统一把所有图片和标签改成纯数字ID顺便把命名不规范的问题一次清理干净from pathlib import Path def rename_clean(split_dir, start_id1): img_dir Path(split_dir) / images / train lab_dir Path(split_dir) / labels / train for img in sorted(img_dir.glob(*.jpg)): new_name f{start_id:05d}{img.suffix} lab lab_dir / f{img.stem}.txt if lab.exists(): new_lab f{start_id:05d}{lab.suffix} img.rename(img.parent / new_name) lab.rename(lab.parent / new_lab) start_id 1参数说明start_id1格式化用05d到99999之前都保持固定位数。数据集更大时改成06d。重命名是数据清洗最后一道保险花几分钟但后续所有脚本都不用在编码问题上绕来绕去。很多标注工具导出时就带UUID文件名名字里混进中文或括号是常态。5. 训练后的验证技巧批量推理出图再决定要不要升级实例分割训练结束不要只看val mAP一晚上的成果最终要落在test集上。这里给两个实用手法一是脚本批量推理并出图人工核对盲区二是用json标签里的segmentation字段判断能不能升级成yolo实例分割。对test集批量预测ultralytics框架下直接用predict接口最省事from ultralytics import YOLO model YOLO(runs/train/underwater_baseline/weights/best.pt) results model.predict(sourcedatasets/underwater/images/test, conf0.25, saveTrue, save_txtTrue, projectruns/predict_underwater, nametest_output) # 打印每张图的检测概况 for res in results: boxes res.boxes print(res.path, len(boxes.cls), boxes.cls.tolist(), [round(float(c), 3) for c in boxes.conf])参数说明saveTrue把带框结果图保存下来save_txtTrue把推理坐标落成txt方便后续统计。conf0.25是置信度门限水下垃圾目标偏小我会拉到0.1到0.15多看候选框再由业务需求决定误检容忍度。人工看完几组图你会发现模型盲区集中在两类远距离小目标和半透明塑料。这是水下数据的自然瓶颈不是模型毛病。想突破的话回到第2章的json标签检查segmentation字段如果多数目标带polygon就把它转成COCO分割标签用yolo实例分割重训一版比单纯调anchor有效得多。数据集边界也要心里有数。5328张对7类来说是能到入门泛化的底料但对密集遮挡、浑浊水体场景还是会overfit。我的习惯是做完baseline后再补1/3相近场景数据用小模型先验证增量有效再上大模型。这条路线走下来返工次数最少。最后说个血泪经验无论数据格式多规范训练前一定要跑一遍标签自检固定随机种子不然模型指标再好换台机器复现出来对不上排查起来极其痛苦。希望这份笔记能帮你在水下垃圾检测方向少走一段路把更多时间留给调模型而不是清理数据。本文还有配套的精品资源点击获取