简介:面向水面漂浮物检测的深度学习视觉数据集,内含2400张实地拍摄的水面垃圾图像,全部经过人工标注并保存为VOC格式XML文件,边界框与类别信息完整,可直接放入Darknet/YOLO框架训练目标检测模型。资源共2000个文件,以jpg图片与配套xml标注为主,另含train.txt、val.txt、test.txt等训练/验证/测试划分列表,以及方便数据预处理的gen.py脚本,压缩包整体约157.68MB。目前已有8555人学习下载,适合环保监测、智能巡检等场景的研究者与开发者使用。借助该数据集可快速构建水面漂浮物识别模型,省去了自行采集与清洗标注的繁琐流程,有助于提升模型在真实水体环境下的泛化能力,也可作为VOC格式数据集制作与Darknet训练流程的参考样例。
1. 2400张水面漂浮物图片,先花半天盘数据再谈训练
「水面漂浮物数据集-2400.zip」听起来就是一个「解压即训」的标准包:拿到手,解压,扔进检测框架,跑一百个 epoch,看 mAP。我头一回处理类似的水面场景数据时就是这样干的,结果验证集上分数漂亮,一到实地测试,漏检和误报一起炸。后来才想明白:问题不在模型,而在没搞清楚「这份数据到底长什么样」就动了手。水面场景的信息量很多时候不在图里,而在标注里——类别分得碎不碎、目标尺寸是几十像素还是几百像素、图像是不是同一批视频抽的帧、数据划分时有没有把相似帧分到训练集和验证集两边。这篇笔记按「先看数据 → 转格式 → 定参数 → 跑训练 → 查坑 → 做增量」的顺序,把 2400 张这类中量级水面数据集完整落地一遍。适合想快速验证漂浮物检测方案、或者准备做河道与库区巡检工程的工程师;新手可以照着命令跑通全流程,熟手可以直接跳到第 5 章看排坑清单。
2. 盘数据的三件事:目录结构、监督信号与数据划分
2.1 解开压缩包先看目录树:三种常见数据组织的识别
拿到 zip 之后,第一件事不是解压到训练目录,而是先看压缩包内部结构。我一般习惯用unzip -l直接列清单,而不是一股脑解压出来再收拾:
unzip -l 水面漂浮物数据集-2400.zip | head -50-l只列出压缩包内的文件清单,不实际解压,这样可以提前判断根目录层级、文件名编码和是否夹带无关文件。常见的目录结构有三种,对应不同的后续处理方式:
| 组织形式 | 典型目录 | 标注内容 | 适合框架 |
|---|---|---|---|
| YOLO 风格 | images/+labels/ | 每张图对应一个.txt,每行class_id cx cy w h(归一化) | YOLO 系列直接训练 |
| VOC 风格 | JPEGImages/+Annotations/ | 每张图对应一个.xml,记录xmin/ymin/xmax/ymax | 早期 SSD、部分检测框架 |
| COCO 风格 | train2017/+annotations/ | 所有标注汇总到一个instances_*.json,含bbox/area/category_id | mmdetection、RT-DETR、Mask R-CNN |
判断出风格之后,再用mkdir -p dataset && unzip 水面漂浮物数据集-2400.zip -d dataset真正解压。需要注意文件名如果是中文,某些 Linux 环境下解压会出现乱码,常见处理是在解压时指定编码:
unzip -O GBK 水面漂浮物数据集-2400.zip -d dataset这一步能避免后面脚本读路径时因为乱码抛异常。解压后建议跑一句find dataset -name "*.jpg" | wc -l核对图片数量,如果数量和标注文件数量对不上,先把缺哪种文件查清楚再继续——我见过不少数据集图片在、标注少几个的情况,这种「差几个」的缺口在训练时不容易被发现,但会悄悄拉低召回率。
2.2 统计 2400 张能提供多少监督信号:类别数量与目标尺寸
对检测模型而言,真正的监督信号不是「图数」,而是「目标实例总数」。一个边界清晰的单类任务,有几万个实例当然好;如果只有几千个,配合预训练权重也能微调出可用的模型。2400 张图如果能提供 5000 到 10000 个框,在 YOLO 系模型上已经足够跑出基线结果。但如果标注里拆出了十来个细分类,每个类别只有两三百个实例,训练时就容易出现 loss 降不动、个别类别学不出来的情况。
所以拿到数据后,我会先跑一个统计脚本,把类别分布和目标尺寸分布打出来:
import os from collections import Counter from PIL import Image image_dir = "dataset/images" label_dir = "dataset/labels" cls_count = Counter() size_count = {"small": 0, "medium": 0, "large": 0} total_boxes = 0 for img_name in os.listdir(image_dir): stem = os.path.splitext(img_name)[0] img_path = os.path.join(image_dir, img_name) lab_path = os.path.join(label_dir, stem + ".txt") if not os.path.exists(lab_path): continue w, h = Image.open(img_path).size with open(lab_path) as f: for line in f: parts = line.split() if len(parts) < 5: continue cls_id = int(parts[0]) bw = float(parts[3]) * w bh = float(parts[4]) * h total_boxes += 1 cls_count[cls_id] += 1 area = bw * bh if area < 32 * 32: size_count["small"] += 1 elif area < 96 * 96: size_count["medium"] += 1 else: size_count["large"] += 1 print("total boxes:", total_boxes) print("class distribution:", cls_count) print("size distribution:", size_count)这个脚本的核心逻辑是:读取每张图片的尺寸,把 YOLO 归一化的框宽高乘回去,得到以像素为单位的真实面积,然后按 MS COCO 的惯例分桶——小于 32×32 算小目标,小于 96×96 算中目标,其余算大目标。统计结果直接决定后面的训练策略:如果大量目标是几十像素的小目标,那么输入分辨率不能一味开大再下采样,数据增强里也得少用会裁剪目标的算子;如果类别分布严重偏斜,就得先做类别归并,再谈训练。
2.3 数据划分:按拍摄批次分层,别让验证集「作弊」
很多人拿到数据集后直接random.shuffle按 8:2 切训练集和验证集,这个动作在水面数据上最容易翻车。原因在于:这类数据集常常是从视频里连续抽帧或者按拍摄批次连续拍摄得到的,同一段水面、同一个漂浮物可能出现在相邻好几张图里。如果这些相邻帧被随机分到训练集和验证集,模型相当于在验证集里「见过」同一个目标,验证分数会虚高 0.1 到 0.2,但一到真实场景立刻打回原形。
我一般的做法是先按文件名中的批次字段分组,再按组划分:
import os import random import shutil from collections import defaultdict random.seed(2025) images = [f for f in os.listdir("dataset/images") if f.endswith(".jpg")] group = defaultdict(list) for img in images: # 假设文件名格式类似 batch01_img0001.jpg,第一段是拍摄批次 batch_key = img.split("_")[0] group[batch_key].append(img) keys = list(group.keys()) random.shuffle(keys) train_keys = set(keys[: int(len(keys) * 0.8)]) val_keys = set(keys[int(len(keys) * 0.8):]) for img in images: batch_key = img.split("_")[0] dest_split = "train" if batch_key in train_keys else "val" src_img = os.path.join("dataset/images", img) dst_img = os.path.join("dataset", dest_split, "images", img) shutil.copy(src_img, dst_img) stem = os.path.splitext(img)[0] src_lab = os.path.join("dataset/labels", stem + ".txt") if os.path.exists(src_lab): shutil.copy(src_lab, os.path.join("dataset", dest_split, "labels", stem + ".txt"))这里的核心是让「同一拍摄批次的图像」整体进入同一个集合,避免数据泄漏。如果文件名里没有批次字段,就需要人工看一眼缩略图,把拍摄时间和场景相近的图归到一组再切。另外,划分完成后建议生成一份文件清单并记录每个文件的校验值,留作后续实验复现的底档。这个习惯相当于给自己留一份后悔药,后面调参发现结果对不上时,能快速确认是不是数据被改动过。
3. 把 zip 变成能训练的干净数据集:可视化、归并与转换
3.1 解压与抽样可视化:先让标注自己说话
统计脚本只能给出数字,真正判断标注质量还是得用眼睛看。我会从数据里随机抽二三十张图,把标注框直接画在原图上,存到一个preview/目录里快速翻阅:
import cv2 import os image_dir = "dataset/images" label_dir = "dataset/labels" preview_dir = "preview" os.makedirs(preview_dir, exist_ok=True) for img_name in os.listdir(image_dir)[:20]: stem = os.path.splitext(img_name)[0] lab_path = os.path.join(label_dir, stem + ".txt") if not os.path.exists(lab_path): continue img = cv2.imread(os.path.join(image_dir, img_name)) h, w = img.shape[:2] with open(lab_path) as f: for line in f: parts = line.split() if len(parts) < 5: continue cls_id, cx, cy, bw, bh = map(float, parts[:5]) x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(os.path.join(preview_dir, img_name), img)画框脚本本身不复杂,但这一步千万别省。重点看三件事:框是否紧贴目标边缘;明显可见的目标有没有成片漏标;同一个类别名下是不是混入了差异极大的目标形态。如果发现标注有系统性问题,后面训练做的所有努力都会被脏数据带偏。水面场景里最常见的视觉问题是半沉水目标、被遮挡目标和反光目标,标注边界往往模糊,看到这类图时心里要有个数——它们会让训练 loss 在后期出现小幅震荡,这属于正常现象,不用过度焦虑。
3.2 类别归并:把碎的监督信号合并成稳的监督信号
统计类别分布之后,多半会遇到一个典型问题:数据集的类别命名很细,比如「矿泉水瓶」「易拉罐」「玻璃瓶」各自独立,但每个类别只有一两百个框。这种细粒度划分在语义上没问题,在训练上却是灾难——同类目标的外观高度相似,把它们拆成多个类,等于强迫模型去学一组几乎相同但标签不同的特征,结果往往是每个类都学不扎实。
我处理这类数据时一般按「材质 + 形态」归并,水面漂浮物可以归成这几组:
| 合并后类别 | 包含的细分类 | 备注 |
|---|---|---|
| 瓶罐类 | 矿泉水瓶、易拉罐、玻璃瓶、饮料盒 | 刚性物体,轮廓清晰 |
| 塑袋类 | 塑料袋、包装膜、编织袋 | 柔性物体,形状多变 |
| 泡沫类 | 泡沫块、泡沫箱碎片 | 白色半浮,易与浪花混淆 |
| 枯枝水草类 | 枯枝、树杈、水草团 | 颜色偏暗,形态不规则 |
| 其他漂浮物 | 无法归入以上类的杂物 | 归并后的兜底类 |
归并的具体操作有两个层面。如果只是改类别编号,直接改标签文件第一列即可;如果要做跨文件、跨目录的批量归并,我建议写一个映射脚本,把细分类 ID 映射到新 ID,并顺手重新统计一次分布。归并完成的标志是:绝大多数类别都有 800 个以上的实例。少于这个量级的类别,在 2400 张图规模下很容易被模型放弃。
3.3 YOLO 与 COCO 格式互转:按需转换,别在项目中途换格式
YOLO 系模型可以直接用labels/*.txt训练,但如果你需要和现有评估管线对结果,或者准备对比 RT-DETR、mmdetection 这类依赖 COCO 格式的框架,就得做一次格式转换。转换脚本的坑主要在坐标换算和类别 ID 偏移上:
import json import os from PIL import Image image_dir = "dataset/images" label_dir = "dataset/labels" images = [] annotations = [] categories = [ {"id": 1, "name": "bottle_can"}, {"id": 2, "name": "plastic_bag"}, {"id": 3, "name": "foam"}, {"id": 4, "name": "wood_weed"}, {"id": 5, "name": "other"}, ] ann_id = 1 for img_id, img_name in enumerate(os.listdir(image_dir), start=1): stem = os.path.splitext(img_name)[0] img_path = os.path.join(image_dir, img_name) lab_path = os.path.join(label_dir, stem + ".txt") if not os.path.exists(lab_path): continue w, h = Image.open(img_path).size images.append({ "id": img_id, "file_name": img_name, "width": w, "height": h, }) with open(lab_path) as f: for line in f: parts = line.split() if len(parts) < 5: continue cls_id = int(parts[0]) cx, cy, bw, bh = map(float, parts[1:5]) x1 = (cx - bw / 2) * w y1 = (cy - bh / 2) * h box_w = bw * w box_h = bh * h annotations.append({ "id": ann_id, "image_id": img_id, "category_id": cls_id + 1, # YOLO 从 0 开始,COCO 从 1 开始 "bbox": [x1, y1, box_w, box_h], "area": box_w * box_h, "iscrowd": 0, }) ann_id += 1 coco = { "images": images, "annotations": annotations, "categories": categories, } with open("instances_train.json", "w") as f: json.dump(coco, f)这段代码里最容易出错的有两处:一是 COCO 的bbox用的是[x, y, width, height],不是两个角点坐标;二是category_id在 YOLO 标签里从 0 开始,在 COCO 里从 1 开始,必须加 1。转换完成后建议马上用可视化工具抽查几张图,确认坐标没有整体偏移。格式转换这种工作属于「不做不行、做一次就够」的事,所以转完之后把脚本留在项目里,后续增量数据可以直接复用。
4. 在2400张上训出能实拍的检测模型:选型、配置与训练
4.1 模型选型:2400张量级为什么首选 YOLO 系
数据量只有 2400 张时,模型选型的第一原则是「别选太贪数据的模型」。检测 Transformer 类模型在 15 万张规模的公开数据上表现很好,但在这个量级上容易欠拟合;两阶段的 Faster R-CNN 训练慢且调参面大;相比之下 YOLO 系模型有成熟的预训练权重、单卡就能跑、增强策略内置,是这个数据规模下性价比最高的选择。
具体到型号,我一般从 YOLOv8s 起步。n 版参数太少,对漂浮物这种形态多变的目标拟合能力偏弱;m 版参数多一倍,但 2400 张图喂不满,训练时间还长;s 版正好。如果你手头有更新版本的 YOLO 权重,同样选 s 规模即可。选 s 的另一个原因是推理速度快,后面做半监督伪标注时要对大量视频帧预测,速度优势会很实际。
4.2 data.yaml 与训练参数:把「水面场景先验」写进配置
训练前需要准备一个数据配置文件。如果你用的是 Ultralytics 系列框架,一份最小配置长这样:
path: /你的绝对路径/dataset train: images/train val: images/val test: images/test nc: 5 names: 0: bottle_can 1: plastic_bag 2: foam 3: wood_weed 4: other注意path要写绝对路径,train和val是相对于path的目录。这里的names必须与标签文件里的类别编号一一对应,编号错一位,训练出来就是一团乱。配置准备好后,训练命令如下:
yolo detect train \ data=dataset/data.yaml \ model=yolov8s.pt \ epochs=100 \ imgsz=640 \ batch=16 \ lr0=0.01 \ mosaic=0.8 \ close_mosaic=10 \ patience=20 \ seed=42mosaic是马赛克增强,把四张图拼成一张训练样本,这个算子对增加背景多样性很有效,但对小目标不友好——拼图时目标被裁剪缩小,几十像素的漂浮物可能直接消失。水面漂浮物大量是小目标,所以我会把默认的mosaic=1.0调到0.8,并且保留close_mosaic=10,让最后 10 个 epoch 关闭马赛克,让模型在贴近真实分布的样本上做收敛。batch=16是 12GB 显存下的保守值,显存更大可以提到 32;patience=20表示验证指标连续 20 个 epoch 不提升就早停,可以省下不少时间。
训练过程中如果看到 loss 曲线前几个 epoch 剧烈震荡,不要急着停,这是学习率从预热切换到稳定阶段的正常现象。但如果 loss 一路发散到 NaN,优先检查是不是标注文件里有非法行,其次是降低lr0到0.005再试。
4.3 训练日志怎么读:从曲线判断「能停在哪一个 epoch」
训练结束后,框架会在runs/detect/train/下输出results.png、confusion_matrix.png、PR_curve.png等图表。我一般先看results.png里的val_mAP50和val_mAP50-95两条曲线。如果mAP50很高但mAP50-95明显偏低,说明模型能把目标框个大概,但边界框位置不稳定,这在半沉目标、被遮挡目标很多的水面数据里很常见——需要回去检查标注边界是不是太含糊。
confusion_matrix.png更值得细看。它会把每个真实类别被预测成哪个类别画成矩阵,对角线越亮越好。如果发现某个类别经常被预测成另一个类别,比如「塑袋类」被大量预测成「泡沫类」,说明这两个类别的形态在模型看来太接近——这时候调整模型意义不大,更有效的做法是回到第 3 章做类别归并,或者给其中一个类别补充更多训练样本。
选择权重文件时,用best.pt而不是last.pt。last.pt是最后一个 epoch 的权重,可能在过拟合边缘;best.pt是验证集指标最好的权重,实际部署更可靠。如果最后发现best.pt是第 30 个 epoch 产生的,而训练跑完了 100 个 epoch,说明后面 70 个 epoch 都在过拟合,下次训练可以把epochs直接降到 60 或者调早停参数。
5. 水面漂浮物检测五处易翻车的坑:现象、原因与排查
5.1 验证集 mAP 0.93,一上实拍就拉胯
现象:训练时验证集 mAP 很高,loss 也收敛得很漂亮,把best.pt部署到一条实际河道视频上,漏检一堆,误检也不少。
原因:这是最典型的数据划分泄漏。数据集里的图像如果是连续视频帧或同一批次连续拍摄的,随机划分会把相似帧分到训练集和验证集两侧,模型在验证集上见过几乎一模一样的目标,分数虚高,实战立刻现原形。
排查方法:回到第 2.3 节,用批次分组的划分方式重建训练集和验证集,保证同一拍摄批次不进两个集合。重新训练后你会看到验证集分数明显下降,但这个分数才是真实可参考的。
5.2 把浪花、树影、桥墩全框出来了
现象:模型对漂浮物的召回不错,但验证集里也出现了大量误检——浪花被框成泡沫,树影被框成枯枝,桥墩被框成其他漂浮物。
原因:训练集里全是「有目标」的正样本,缺少「水面但没有目标」的负样本。模型在训练时没见过「看起来像目标但其实不是」的场景,预测时就倾向于把一切纹理显著的区域框出来。
解决:从现场素材里切出确实没有漂浮物的画面,单独放到一个目录,在训练中作为负样本参与。需要注意,很多框架默认会跳过空标签的图片,你需要确认框架配置里允许空标签文件参与训练,或者把这些负样本图放在验证集里,通过调高置信度阈值来压误检。一个更省事的替代方案是收集误检图片,把它们手动标注一个「背景类」框加入训练,类别数量加 1,模型学完通常能明显改善误检。
5.3 瓶子类学得不错,枯枝水草类一塌糊涂
现象:从训练曲线看,总 mAP 一路走高,但单独看某个类别的 AP,差异极大。瓶子、塑料瓶罐这类刚性物体识别得很好,枯枝、水草团这类不规则目标几乎检不出来。
原因:类别样本量不平衡。刚性物体在数据里出现频率高、外观一致;枯枝水草出现频率低、形态千变万化。模型把学习容量都花在了高频类别上,低频类别自然学不好。
解决:先做类别归并,把枯枝和水草合并成一个「生物质类」,降低学习难度;再对少数类做离线增强,比如复制少样本图并做随机旋转、亮度调整、缩放,让它们的样本占比从 5% 提到 15% 以上;如果归并后仍不足,接受这个类别的 AP 上限,在部署阶段单独判断。
5.4 小目标在训练中被马赛克裁丢
现象:单独验证时发现,凡是像素面积小于 30×30 的目标,几乎全部漏检;大目标全都正常。
原因:马赛克增强把四张图缩放到同一张画布里,原本就小的目标缩放后可能只有十几个像素,模型的下采样特征图里已经感知不到这个目标。尤其在输入分辨率用 640 而目标原始尺寸只有二三十像素时,经过连续下采样,目标可能在特征图上只剩一两个像素。
解决:把mosaic降到 0.5 甚至关闭;训练时保持imgsz=640,不要为了提速降到 416;如果显存够,可以用imgsz=768或imgsz=1280重新训练,小目标召回通常会有明显提升。另外确认标签里小目标框的x、y、w、h都是正数,出现过零值或负值会导致采样时崩溃。
5.5 阴天、逆光、夜间全部失效
现象:训练集里大多是晴天顺光拍摄的图,模型在正常光照下表现不错,但到了阴天、逆光或者傍晚,mAP 直接掉到原来的一半以下。
原因:水面本身的反射特性让光照变化对图像影响极大,逆光时漂浮物和背景的对比度接近为零,模型学习到的纹理特征全部失效。
解决:训练时打开色彩增强参数,把 HSV 的饱和度扰动和亮度扰动调大;更有效的方式是从现场补拍一些逆光、阴天、雾天的图,哪怕只有一两百张,加入训练后也能显著提升鲁棒性。这一条没有捷径,数据多样性是唯一可靠的解法。
6. 用半监督伪标注扩大数据池,用固定种子守住评测底线
2400 张图的模型训完之后,最值得做的下一件事是「让数据自己长出来」。把训练好的模型部署到一段新的水面视频上,对每一帧做预测,然后用置信度阈值筛掉低分预测,再按目标连续性确认——同一个目标不会只在一帧出现,连续几帧都被检出且类别一致的框才被保留。这样可以从一段一小时视频里,得到几百张带初步标注的新样本。伪标注不是直接进训练集,而是抽出一两百张让人工快速复核,修正明显错框后再合入原数据集。这个循环跑三轮,数据池通常能从 2400 张扩到五六千张,模型精度会再上一个台阶。命令行层面可以用框架自带的预测接口先跑一批:
yolo detect predict \ model=runs/detect/train/weights/best.pt \ source=new_video_frames/ \ conf=0.85 \ save_txt=True \ save_conf=Trueconf=0.85是关键参数,它决定了伪标注的「保守程度」。阈值设太高,召回的新样本太少,扩量效果有限;设太低,错误标注混入训练集,反而拉低模型精度。建议先开到 0.9 跑一遍,人工看看预测质量再下调。save_conf=True会把置信度写进标签文件,后续筛选时可以按阈值过滤,不必重新预测一遍。
另一件值得投入时间的事是把实验流程「锁死」。我现在的做法是:每个实验固定随机种子,把验证集文件清单永久保留,并写一个脚本把模型在验证集上逐张图的预测结果缓存成 CSV——包括图片名、真实类别、预测类别、置信度、框坐标。这样无论是换了增强参数还是换了模型结构,都可以回到同一份缓存上做精细对比,而不是拿「两次验证集略有差异的 mAP」讲故事。曾经有一次我改完增强参数后重跑,结果 mAP 少了 0.03,我以为是增强策略变差了,查了两天才发现是随机种子没固定,初始化权重不同导致基线差异。从那以后我再也不把随机变量留在实验流程里。这个习惯建议你尽早养成:固定种子、固定验证集、缓存预测结果。希望这个流程能帮你在水面漂浮物检测这个方向上少走一段弯路。
本文还有配套的精品资源,点击获取