简介:面向水下垃圾检测与水域智能监测场景的7类目标检测数据集,由水下机器人拍摄的5328张实景图片构成,类别涵盖水下生物、塑料垃圾、金属垃圾、木头垃圾、橡胶垃圾、布料垃圾和捕鱼工具垃圾,能够有效缓解水下垃圾样本稀缺、标注成本高的问题。图片内容覆盖多种水质与光照条件,分布较为均匀,标注精准,可直接支撑水下垃圾检测、水环境质量监测等实际项目,也可用于课程作业、算法设计与竞赛训练。资源包共21313个文件,以jpg原图为主体,同步提供voc(xml)、yolo(txt)、json三种标签格式,分别适配不同标注规范与训练框架,省去手工转换环节;压缩包体积约127.54MB,整体结构简洁,便于按类别和格式筛选使用。目前已有325人学习下载,适合需要高质量多标签水下垃圾数据的开发者直接开展模型训练、验证与对比实验。
1. ROV 巡检项目里最卡进度的往往不是模型选型,而是拿不到干净的数据集
这个水下垃圾检测数据集把 5328 张真实水下图像和三类标签(Pascal VOC 的 xml、YOLO 的 txt、JSON)放在一个 zip 里,等于把“标注到训练”的中间步骤压缩成了解压和格式整理。适合正在做水下目标检测的算法工程师、做水下机器人毕业设计的学生,以及刚入门 YOLO 的学习者。下面按“拿到 zip 之后怎么办”的顺序展开,把 7 类标签怎么管理、三种格式怎么统一、训练时最容易翻车的地方讲透。
水下图像的水体吸收、悬浮颗粒和暗光环境,会让标注质量比常规数据集更难验证。这也是为什么很多人在 YOLO 上跑通用目标检测很顺,一换到水下场景就莫名掉点。问题通常不在网络结构,而在标签本身的坐标基准和类别编号没有对齐。这篇文章就围绕这个核心矛盾展开。
2. 先识货再动手:7 个类别、5328 张图与三种格式的标注规范
2.1 解压后先看清目录结构:一张图对应三类标签的常见布局
拿到 zip 的第一步不是急着打开 json 看内容,而是把压缩包完整解压出来,先摸清目录布局。常见做法是分成 images、Annotations、labels 三个目录,xml 与 jpg 同名,txt 和 json 有的独立成目录、有的混放在 labels 下。用下面两条命令就能把结构看明白。
unzip 水下垃圾检测数据集7类-5328张-含voc(xml)+yolo(txt)+json三种格式标签.zip -d underwater_garbage find underwater_garbage -maxdepth 2 -type d | sort find underwater_garbage/Annotations -name "*.xml" | wc -l第一行解压时保留原目录层级,避免把散落的文件直接倒进当前目录。第二行看目录树,确认有没有 images、Annotations、labels 这样的标准结构。第三行统计 xml 数量,正常情况下应该等于 5328。如果数量对不上,说明打包时可能漏了部分标注,后续转换前要做完整性补齐。
拆开后不要急着数文件,直接用 wc -l 看三个目录的文件数是否都等于 5328。这里有个很容易被忽略的细节:有些 xml 和 txt 的文件名大小写不一致,例如图片是IMG_1024.jpg,标签却是img_1024.xml,这在 Windows 上不明显,一放到 Linux 训练环境就会全部匹配失败。所以目录结构确认之后,紧接着还要做一次文件名配对检查,这一步在下一章会给出可复用脚本。
2.2 7 类水下垃圾的分类逻辑:材质比外形更抗水体干扰
标题只写了“7类”,没有给出具体类别名。按这类数据集最常用的标注约定,我一般按材质来分:塑料、金属、玻璃、橡胶、渔网、木材、混合垃圾。以材质而不是外形分类,是因为水下图像经过水体吸收和散射之后,轮廓信息衰减得比纹理严重,而同一种材质的反射特征相对稳定。
| 类别(常见约定) | 水下辨识要点 | 容易混淆目标 |
|---|---|---|
| plastic 塑料 | 偏亮、半透明、有柔光 | 水母、白色砂石 |
| metal 金属 | 锈迹或镜面反光 | 深色岩石光泽面 |
| glass 玻璃 | 局部高光、边缘锐利 | 盐类结晶、鱼鳞反光 |
| rubber 橡胶 | 暗哑、有弹性形变 | 海藻缠绕的条状物 |
| fishing_net 渔网 | 规则网眼、线绳堆叠 | 纤维絮状物 |
| wood 木材 | 木纹、浮力导致斜插 | 沉底树干、珊瑚残片 |
| mixed 混合垃圾 | 多材质粘接、形状不规则 | 一切被遮挡的单一类别 |
混合垃圾单独成一类是避坑关键。真实水底很少有单一目标,一团缠着塑料袋的废渔网,如果强行归到渔网类,会让模型在 mAP 评估时因为类别定义模糊而反复波动。标注时宁可把边界模糊的框归入 mixed,也不要让两个类别在特征空间里重叠。
5328 张作为基座训练其实偏小,更常用的做法是拿它做领域微调。水下真实数据要靠 ROV 搭载 d435i 这类深度相机去采集,人工扩充成本极高,所以这个规模适合验证算法在浑浊水域的可行性,而不是追求刷榜精度。
2.3 三种格式的字段差异:xml、txt、json 各自多记了什么
| 维度 | Pascal VOC(xml) | YOLO(txt) | JSON |
|---|---|---|---|
| 坐标基准 | 像素绝对坐标 | 归一化坐标 | 常见为绝对像素,也有的存归一化 |
| 边界框书写方式 | xmin, ymin, xmax, ymax | x_center, y_center, width, height | x, y, width, height |
| 类别记录 | 字符串 name | 序号 class_id | 通常为 category_id |
| 附加元数据 | 图片宽高、标注人、难度 | 无 | 图片路径、license、分割信息 |
| 定位难度 | 信息最全 | 需要配合图片尺寸解释 | 结构相对统一但键名各异 |
xml 最啰嗦,却是三种格式里唯一自带图片宽高的;txt 最精简,但可读性差;json 是三种格式里最不统一的,常见的是 COCO 风格,也见过直接把归一化坐标塞进去的自定义结构。JSON 文件不像 xml 那样能被浏览器直接优雅展示,常见做法是用支持高亮的编辑器打开,或者用python -m json.tool做格式化校验。
这三种格式出现在同一个数据集里,大概率是工作流的产物:标注平台导出 Pascal VOC,训练脚本用 YOLO txt,巡检系统要存 JSON 方便对接数据库和后端接口。对使用者来说,三格式齐全的收益是省去了到处找转换工具的时间,但代价是你必须理解它们之间坐标基准的换算,否则就是拿着金矿当石头。
3. 从 zip 到训练脚本:三格式解析与 YOLO 训练的最小可复现路径
3.1 检查图与标签配对:训练前必须过的数据完整性关卡
在转换格式之前,先写一个配对脚本,把没有标签的图和没有图的标签找出来。这一步能顺便暴露文件名大小写不一致的问题,比后面训练时等报错高效得多。
from pathlib import Path base = Path("underwater_garbage") images = {p.stem for p in (base / "images").glob("*.jpg")} xmls = {p.stem for p in (base / "Annotations").glob("*.xml")} print("有图无xml:", len(images - xmls)) print("有xml无图:", len(xmls - images))这里的关键是用stem而不是完整文件名做集合元素,这样xxx.jpg和xxx.xml才能通过去掉后缀后的名称关联起来。输出结果如果大于 0,先看是缺文件还是大小写不一致。常见做法是直接用os.rename统一成大写或小写文件名,再重新跑一遍这个脚本。
这一步不能省。水下数据集的图像很多来自不同采集批次,文件命名可能混着IMG、DSC、frame_多种前缀,整理时稍不留神就会漏配对。等到训练日志里出现 no labels found 再去查,定位成本会成倍上升。
3.2 用 Python 把 Pascal VOC 的 xml 转成 YOLO 所需 txt
xml 是三种格式里信息最完整的,所以优先以它为基准做转换。下面这段脚本读取每个 xml 的 size 字段完成归一化,并把类别名映射为从 0 开始编号的 class_id。
import xml.etree.ElementTree as ET from collections import Counter from pathlib import Path CLASSES = ["plastic", "metal", "glass", "rubber", "fishing_net", "wood", "mixed"] unknown_names = Counter() def voc_to_txt(xml_path: Path, out_dir: Path) -> None: root = ET.parse(str(xml_path)).getroot() w = int(root.findtext("size/width")) h = int(root.findtext("size/height")) lines = [] for obj in root.iter("object"): name = obj.findtext("name") if name not in CLASSES: unknown_names.update([name]) continue cls_id = CLASSES.index(name) box = obj.find("bndbox") x1 = float(box.findtext("xmin")) y1 = float(box.findtext("ymin")) x2 = float(box.findtext("xmax")) y2 = float(box.findtext("ymax")) x_c = (x1 + x2) / 2.0 / w y_c = (y1 + y2) / 2.0 / h bw = (x2 - x1) / w bh = (y2 - y1) / h lines.append(f"{cls_id} {x_c:.6f} {y_c:.6f} {bw:.6f} {bh:.6f}") out_dir.mkdir(parents=True, exist_ok=True) (out_dir / f"{xml_path.stem}.txt").write_text("\n".join(lines), encoding="utf-8") xml_root = Path("underwater_garbage/Annotations") txt_root = Path("underwater_garbage/labels") for p in xml_root.glob("*.xml"): voc_to_txt(p, txt_root) print("未知类别统计:", dict(unknown_names))坐标用的不是 xmin、ymin 直接归一化,而是先取中心点(x1 + x2) / 2再除以图片宽高,这是 YOLO 系列的统一约定。输出保留 6 位小数,足够满足 float 精度需求。类别清单CLASSES的顺序一旦确定,后续 json 转换、data.yaml 里都必须沿用同一套顺序。如果这张表的顺序变了,之前生成的 txt 全部作废。
脚本里额外加了一个unknown_names计数器。运行结束后打印出来的未知类别如果非空,不要简单跳过,而是先打开原始 xml 看这些类别是标注笔误还是真有多余类别,再决定是合并进 CLASSES 还是单独处理。
3.3 JSON 标签的读取与转存:兼容两种常见结构
json 的麻烦在于结构不统一。常见做法是先判断整体是一个数组还是 COCO 式字典,再决定解析路径。下面这段脚本按 image_id 分组,并根据 bbox 数值范围判断坐标是绝对像素还是归一化坐标。
import json import cv2 from pathlib import Path def json_to_txt(json_path: Path, image_dir: Path, out_dir: Path) -> None: with open(json_path, encoding="utf-8") as f: data = json.load(f) anns = data if isinstance(data, list) else data.get("annotations", []) if not anns: return groups = {} for ann in anns: groups.setdefault(ann.get("image_id", 0), []).append(ann) out_dir.mkdir(parents=True, exist_ok=True) for image_id, items in groups.items(): img_path = image_dir / f"{image_id}.jpg" img = cv2.imread(str(img_path)) if img is None: print(f"缺少图片 {image_id}.jpg,跳过其标签") continue h_img, w_img = img.shape[:2] lines = [] for ann in items: cls_id = ann["category_id"] x, y, bw, bh = ann["bbox"] if max(x + bw, y + bh) <= 1.01: # bbox 已经是归一化坐标,直接取中心点 x_c = x + bw / 2 y_c = y + bh / 2 lines.append(f"{cls_id} {x_c:.6f} {y_c:.6f} {bw:.6f} {bh:.6f}") else: x_c = (x + bw / 2) / w_img y_c = (y + bh / 2) / h_img lines.append(f"{cls_id} {x_c:.6f} {y_c:.6f} {bw / w_img:.6f} {bh / h_img:.6f}") (out_dir / f"{image_id}.txt").write_text("\n".join(lines), encoding="utf-8")判断归一化坐标的阈值取 1.01 而不是 1.0,是因为少数标注工具会生成width=0.99998这类浮点噪声。这里用max(x + bw, y + bh)看边界框是否落在画面内,比单纯看bw <= 1更稳,能同时排除 bbox 起点偏移导致越界的情况。
这段脚本假设 json 是 COCO 式结构。如果整个 json 是数组,且每个元素没有image_id或image_path字段,需要先补一层文件名映射,否则无法安全分组。我在实际处理中见过把图片路径写进 bbox 数组里当注释的自定义格式,那种只能写定制解析器,没有通用做法。
提示:json 里的图片尺寸有时和 xml 的 size 不一致,务必以你实际读到的图像宽高为准,不要信任 json 里可能存在的元数据字段。
3.4 用 data.yaml 锁定类别顺序并启动训练
txt 转换完成之后,需要把数据划分成 train/val 两个子集。我习惯用固定随机种子做划分,保证后续实验可复现。
mkdir -p images/train images/val labels/train labels/val python - << 'EOF' import random from pathlib import Path imgs = list(Path("images").glob("*.jpg")) random.seed(42) random.shuffle(imgs) val_ids = set(p.stem for p in imgs[:532]) for p in imgs: dst = "val" if p.stem in val_ids else "train" p.rename(f"images/{dst}/{p.name}") lab = Path("labels") / f"{p.stem}.txt" if lab.exists(): lab.rename(f"labels/{dst}/{lab.name}") EOF这段脚本按 10% 左右的比例划分验证集。random.seed(42)保证多次运行结果一致,避免每次划分不同导致评估指标不可比。只移动images和labels,xml 和 json 可以留在原目录做备份,后续有需要再回来对照。
然后写 data.yaml:
path: underwater_garbage train: images/train val: images/val nc: 7 names: 0: plastic 1: metal 2: glass 3: rubber 4: fishing_net 5: wood 6: mixed最后启动训练。在 PyCharm 里配好解释器后,可以直接跑下面的命令,也可以换成python -m ultralytics的调用方式。
pip install ultralytics yolo train data=underwater_garbage/data.yaml model=yolov8n.pt epochs=100 imgsz=640 batch=16imgsz=640是通用目标检测的常用值,但水下小目标比较多,建议提到 768 或 832 再对比一轮。显存不够时先把 batch 降到 8,不要先降 imgsz。类别顺序在这里是一次定生死的事:txt 里的 class_id 必须与 yaml 的 names 下标一一对应,否则训练日志再漂亮,输出的预测类别也是错位的。
4. 水下场景的标注一致性避坑:从 xml 到 txt 的 5 个常见翻车现场
水下场景的高光、浑浊和悬浮物让标注本身像黑匣子,很多翻车不是在模型结构上,而是在标签格式上。下面这 5 个问题我每次处理水下数据集都会撞见一两个,按现象到原因再到解决方案的顺序说清楚。
4.1 中文类名与英文编号错位:第一列类别对不上的根因
现象:转换出来的 txt 第一列编号和 json 里的 category_id 对不上,训练 loss 能下降,验证 mAP 却很低,混淆矩阵里所有类别都糊成一团。
原因:xml 里写的是“塑料瓶”,json 里写的是 plastic,转换脚本用字符串索引 CLASSES,两边对不齐。有的数据集还混着中文、英文、拼音三种命名,打包人自己都没意识到。
解决:先跑一遍类别统计,把所有 xml 中出现的 name 打印出来,再以 json 的 category_id 为主键建立名称映射。不要直接在转换脚本里用CLASSES.index(name)一把梭,至少要加一个别名表,把“塑料瓶”“塑料”“plastic”都映射到同一个 id。
4.2 边界框越界导致损失函数计算异常
现象:loss 在训练中后期变成 NaN,或者 val mAP 在几个 epoch 后突然掉到 0,且日志里没有明显报错。
原因:标注工具在图片边缘拖拽时产生的坐标超出 width 和 height。xml 能正常读出来,但归一化后出现1.02这类越界值,YOLO 的损失函数在边界框回归时计算出无效梯度。
解决:转换脚本里对坐标做 clip 到[0, 1]区间,同时额外检查 xml 的 size 与 json images 字段里的宽高是否一致。不一致时直接告警,不要静默吞掉。
4.3 空 txt 文件把训练集静默架空
现象:训练日志里出现found 0 images for train,但 images 目录下明明有图。
原因:部分无目标图像存在空 xml,转换后生成 0 字节 txt。YOLO 的 dataset 索引会认为“有图无标签”,把整张图丢掉,而不是当作负样本。
解决:转换后加一个过滤步骤,把 0 字节的 txt 移动到empty_labels/目录单独保存,再生成一个negative.txt记录这些无目标图。后续如果要训练带背景负样本的模型,可以把这批图单独引回来。
4.4 类别数量严重倾斜:5328 张里某一类只有几十个框
现象:塑料和渔网加起来占了七成,玻璃、混合垃圾只有几十个框。模型在多数类上表现尚可,少数类几乎完全不 Recall。
原因:水下垃圾的自然分布就是这么偏斜,实拍数据很难均衡。
解决:划分 train/val 时不要按图片数切,要按框的类别数做分层抽样,保证验证集里每个类别至少有 20 个目标,否则 mAP 在少数类上的波动会大到没法用。必要时要对少数类做重复采样,先跑通流程再谈类别均衡。
4.5 EXIF 方向信息导致坐标错位
现象:训练结束可视化时发现框和目标整体错位一个固定角度,像是坐标系被旋转了。
原因:jpg 自带 Orientation 的 EXIF 信息,xml 里的 width/height 是按旋转后的画幅记录的,而 OpenCV 默认不应用 EXIF 旋转,读出来的数组方向与标注时的参考图不一致。
解决:转换前统一用cv2.imread读一遍图片,再用cv2.imwrite重写一份去掉 EXIF 的副本,以副本为基准重新生成 xml 和 txt。ROV、GoPro 直出的图片基本没这个问题,手机拍摄的水下素材中招概率很高。
5. 用 mAP 和混淆矩阵验收数据集:三个技巧
5.1 先跑一个短训练,再用 yolo val 和 yolo confuse 看类别串扰
拿一个刚转好的数据集直接训 100 epoch 是浪费,先训 20 epoch 验数据质量更实在。训练完成后用同一套权重跑验证集和混淆矩阵:
yolo train data=underwater_garbage/data.yaml model=yolov8n.pt epochs=20 imgsz=640 yolo val model=runs/detect/train/weights/best.pt data=underwater_garbage/data.yaml yolo confuse model=runs/detect/train/weights/best.pt data=underwater_garbage/data.yaml混淆矩阵里如果 glass 和 plastic 互相串,说明边界框本身标得虚,高光区域把框拉到了垃圾边缘;如果 wood 和 mixed 串,多半是标注时把模糊样本归错类。这一步能快速把标签质量问题从模型训练策略里分离出来。
5.2 用目标尺寸分布判断 imgsz 要不要往上提
from pathlib import Path import numpy as np boxes = [] for txt in Path("underwater_garbage/labels/train").glob("*.txt"): for line in txt.read_text().strip().splitlines(): parts = line.split() if len(parts) >= 5: boxes.append((float(parts[3]), float(parts[4]))) arr = np.array(boxes) print("框宽中位数px(按640输入):", np.median(arr[:, 0]) * 640) print("小目标占比:", np.mean((arr[:, 0] < 32 / 640) | (arr[:, 1] < 32 / 640)))这段统计的是归一化尺寸换算到 640 分辨率后的像素大小。如果中位数小于 40px,或者小目标占比超过两成,imgsz=640大概率会把目标缩没了,换成imgsz=768再训练是性价比最高的调整。不要一上来就换大模型,先把输入分辨率对清楚。
5.3 把预测框画回原图,逐张抽检
yolo predict model=runs/detect/train/weights/best.pt source=underwater_garbage/images/val save_txt=True save_conf=True预测结果会输出到runs/detect/predict/,直接打开看预测框和目标的贴合程度。这个办法听起来土,却最能在正式投入前暴露问题。框把目标切了一半、两个目标被并成一个框、漏掉暗角里的小目标,这些在 mAP 曲线里很难直观发现,画在图上是一眼的事。
我自己拿到这类三格式数据集,第一件事永远是先写一个一致性校验脚本,把 xml、txt、json 三者的类别数量和坐标个数分别导出,交叉比对。这个习惯帮我避免过至少两次因为类别错位导致的返工。先把数据摆正,再谈训练,这半小时省下来的调试时间远比想的多。希望帮到你。
本文还有配套的精品资源,点击获取