简介:面向水下目标检测任务,此数据集聚焦海参、扇贝等常见海洋生物,采用VOC标准标注格式,每张JPG图像均有对应的XML标签文件,类别明确、边界框完整,省去自行采集和标注的繁琐环节,可直接用于YOLO、Faster R-CNN等目标检测模型的训练与评估。资源共计601个文件,包含299张原始图像与299份XML标注文件,并附有JSON、PNG及PY辅助文件,用于类别配置、示意图与数据处理;包体仅5.35MB,体积小巧、下载快,便于放入各类实验环境。数据按文件夹分类保存,classes文件清晰说明了3个类别(海参、扇贝等),整体结构直观,可快速完成数据划分。已有92人学习,对于水下目标检测的入门者或需快速验证检测框架的开发者而言,这套数据提供了一条低门槛的起步路径,其标注质量可支撑算法调参、结果对比和模型改进等环节。
1. 水下海参扇贝目标检测数据集:先弄懂这张图和两份标签
要做水下海参、扇贝的目标检测,大多数人第一反应是找模型架构,结果卡在第一步:连一张像样的训练数据都凑不齐。市面上的目标检测数据集很多,但针对水下生物、带VOC标注格式、图像和标签一起给完整的少之又少。这篇笔记直接按这类数据集的落地流程讲:它是什么目录结构、怎样把VOC格式转成训练能用的YOLO格式、训练时哪些参数需要动,以及我实际跑水下项目时踩过的几个坑。适合做ROV捕捞识别、养殖巡检、水下视觉方案验证的工程师,照着做可以把数据直接喂给YOLO系模型,少走弯路。
2. VOC标注格式里有什么:先读懂数据和标签的真实结构
拿到一份水下海参、扇贝检测数据集,第一步不是急着训练,而是把压缩包里的目录结构彻底看一遍。VOC标注格式脱胎于Pascal VOC竞赛的标注约定,因为字段直白、目录规范,后来被学术界和工业界广泛当成数据集交换的标准格式之一。很多公开水下目标数据集也沿用了这套组织方式,解压之后通常会看到JPEGImages、Annotations、ImageSets这几个目录,外加一份类别说明文件。搞清楚每个目录干什么,后面的转换、训练和排查才有依据。
2.1 一张VOC格式的数据集,打开后应该看到什么
VOC标注格式的顶层结构并不复杂,核心是图像目录和标签目录两个部分。图像放在JPEGImages下,标签放在Annotations下,训练集与验证集的划分信息放在ImageSets/Main里。实际下载水下海参、扇贝数据集时,有些打包者会额外放一个README或class_names.txt,没有也不要紧,类别名可以从XML里读出来。
| 目录或文件 | 内容 | 在训练流程中的作用 |
|---|---|---|
| JPEGImages/ | 水下海参、扇贝原始图像,jpg或png格式 | 训练和验证的输入图片 |
| Annotations/ | 与图像同名的XML标签文件 | 保存每个目标的类别与边界框 |
| ImageSets/Main/ | train.txt、val.txt等文件名索引 | 决定哪些图参与训练、哪些做验证 |
| class_names.txt | 类别名到id的映射 | 格式转换时保证类别编号稳定 |
这里最需要注意的是Annotations下的XML文件名与JPEGImages下的图像文件名一致。文件名不一致多半是数据集打包时改过名,但XML里的filename字段没有同步更新。这类问题最好在开始阶段就暴露出来,否则训练几十个epoch之后才看到框乱飞,排查成本很高。
2.2 一个XML标签怎么读:核心字段与容易漏掉的size
打开任意一个XML,内容大致是这样的:
<annotation> <folder>JPEGImages</folder> <filename>img_015.jpg</filename> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <object> <name>sea_cucumber</name> <bndbox> <xmin>512</xmin> <ymin>540</ymin> <xmax>780</xmax> <ymax>745</ymax> </bndbox> </object> </annotation>与训练直接相关的是三组信息:filename是图像名,object的name是类别名,bndbox里是目标的左上角和右下角坐标。这个坐标系是像素坐标系,x向右增大,y向下增大。水下海参、扇贝的标注一般就是水平框,不需要按照旋转框处理。很多新手只盯着bndbox看,忽略了size字段,其实size字段记录了标注当时的图像宽高,一旦图像预处理阶段做了裁剪、去畸变或缩放而size没同步更新,后面做归一化转换时就会得到一堆错误坐标。
2.3 用Python给数据集做体检:类别数量、目标尺寸与越界检查
动手训练前,我习惯先写一个统计脚本给数据集做体检。目的有三个:一是核对图像和XML能不能对上;二是看类别分布和标注框大小,判断后续数据增强策略;三是把越界标注提前捞出来,避免污染训练。
# voc_stats.py # 使用方式:把脚本放到数据集根目录,改下面的路径后直接执行 python voc_stats.py import os import xml.etree.ElementTree as ET from glob import glob from PIL import Image IMG_DIR = "JPEGImages" # 图像目录 ANNO_DIR = "Annotations" # XML目录 # 先收集所有图像的文件名主干,用于核对xml与jpg是否一一对应 img_stems = {os.path.splitext(os.path.basename(p))[0] for p in glob(os.path.join(IMG_DIR, "*"))} stats = {} size_bins = {"small": 0, "mid": 0, "large": 0} total = 0 for xml_path in glob(os.path.join(ANNO_DIR, "*.xml")): root = ET.parse(xml_path).getroot() fname = root.find("filename").text stem = os.path.splitext(fname)[0] # 图像缺失时直接提示,不要静默跳过 if stem not in img_stems: print(f"[文件名不一致] {xml_path} 指向 {fname}") continue size = root.find("size") W = int(size.find("width").text) H = int(size.find("height").text) # 用真实图像尺寸去校验xml里的size字段 with Image.open(os.path.join(IMG_DIR, fname)) as im: if im.size != (W, H): print(f"[尺寸不一致] {fname} xml={W}x{H} 实际={im.size[0]}x{im.size[1]}") for obj in root.findall("object"): total += 1 name = obj.find("name").text.strip() stats[name] = stats.get(name, 0) + 1 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) # 允许1像素容差,这是标注工具四舍五入带来的正常误差 if xmin < -1 or ymin < -1 or xmax > W + 1 or ymax > H + 1: print(f"[越界] {fname} 中的 {name} 标注超出图像范围") area = (xmax - xmin) * (ymax - ymin) # small: 面积小于图像的1%,mid: 1%~25%,large: 大于25% if area < (W * H * 0.01): size_bins["small"] += 1 elif area < (W * H * 0.25): size_bins["mid"] += 1 else: size_bins["large"] += 1 print(f"目标总数: {total}") print(f"类别分布: {stats}") print(f"尺寸分布: {size_bins}")这个脚本的逻辑是先把图像文件名存成集合,再逐个解析XML,最后统计目标数量、类别分布和框的尺寸分布。脚本运行时间通常在几十秒到几分钟,取决于图像数量。如果输出里出现“尺寸不一致”或“越界”,不要直接忽略,至少要回到原始XML里确认是标注错误还是统计脚本误报。
看统计结果时不要只看目标总数。水下海参、扇贝在画面里往往只占很小一块,如果small占比超过70%,训练策略就要调整。比如输入端resize到640时,小目标可能只有十几个像素,模型很难学到有效特征。这时候优先考虑把imgsz提高到960或1280,而不是盲目堆模型参数量。体检完一遍,心里对数据有了底,再去做格式转换才不会翻车。
3. 把VOC转成YOLO格式:转换脚本与训练集划分
YOLO系列模型不读XML。无论是YOLOv5、YOLOv8还是YOLO11,训练时用的标签都是纯文本文件,每行五个数值:类别id、归一化中心点x、归一化中心点y、归一化宽、归一化高。因此拿到VOC标注格式的数据集之后,第一步永远是做格式转换。很多初学者试图给训练框架写CustomDataset来直接解析XML,这个方向不是不行,但没必要,转换一次一劳永逸,排查问题也直观得多。
3.1 YOLO与VOC的框表达差异:为什么必须做换算
VOC的bndbox记录的是左上角(xmin, ymin)和右下角(xmax, ymax),单位是像素。YOLO标签记录的是目标中心相对于图像的坐标,以及目标宽高相对于图像的比值,全部归一化到0到1之间。换算公式看起来简单,但很容易错:
cx = (xmin + xmax) / 2 / W cy = (ymin + ymax) / 2 / H bw = (xmax - xmin) / W bh = (ymax - ymin) / H
常见错误是忘除以图像宽高,导致中心点和宽高值远超1,训练时边界框直接飞出画面。另一个隐蔽错误是直接用XML里的size字段,而不是读取真实图像尺寸,因为图片可能被预处理脚本缩放或裁剪过,XML里的size并没有同步更新。转换脚本要用PIL读取图像真实宽高作为分母。
3.2 VOC转YOLO转换脚本:带类别白名单与越界截断
下面这个脚本可以直接复制到数据集根目录执行,核心是把每个XML转换成一个同名的txt文件,写入YOLO标签。
# voc2yolo.py import os import xml.etree.ElementTree as ET from glob import glob from PIL import Image # 类别顺序必须和后续训练yaml里的names严格一致 CLASSES = ["sea_cucumber", "scallop"] IMG_DIR = "JPEGImages" ANNO_DIR = "Annotations" OUT_DIR = "labels" if not os.path.exists(OUT_DIR): os.makedirs(OUT_DIR) for xml_path in glob(os.path.join(ANNO_DIR, "*.xml")): root = ET.parse(xml_path).getroot() filename = root.find("filename").text # 读取真实图像的宽高,避免使用XML里可能失真的size字段 try: with Image.open(os.path.join(IMG_DIR, filename)) as im: W, H = im.size except FileNotFoundError: print(f"[跳过] 找不到图像 {filename}") continue lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() # 类别白名单:名字不在CLASSES里的目标直接忽略,但打印出来提示 if name not in CLASSES: print(f"[未知类别] {filename} 中的 {name} 不在CLASSES里") continue cls_id = CLASSES.index(name) box = obj.find("bndbox") xmin = float(box.find("xmin").text) ymin = float(box.find("ymin").text) xmax = float(box.find("xmax").text) ymax = float(box.find("ymax").text) cx = ((xmin + xmax) / 2) / W cy = ((ymin + ymax) / 2) / H bw = (xmax - xmin) / W bh = (ymax - ymin) / H # 截断到[0,1],防止极个别越界标注导致训练崩溃 cx = min(max(cx, 0.0), 1.0) cy = min(max(cy, 0.0), 1.0) bw = min(max(bw, 0.0), 1.0) bh = min(max(bh, 0.0), 1.0) lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") if not lines: continue stem = os.path.splitext(filename)[0] with open(os.path.join(OUT_DIR, stem + ".txt"), "w", encoding="utf-8") as f: f.write("\n".join(lines)) print("转换完成,请检查上一级目录下的labels文件夹")CLASSES列表的顺序是整个转换脚本里最容易出问题的点。假如之前有人把类别顺序写成["scallop", "sea_cucumber"],那么同一个名字转换出来的数字id就全变了,训练yaml里的names顺序只要没跟上,模型就学不到正确类别。我一般会把CLASSES和之后的yaml放到同一个文件里维护,避免分处两地导致不同步。
转换后用一行命令侧视验证:打印任意一个txt文件,确认第一列是0或1,其余四个数都在0到1之间。如果出现超过1的值,说明输入尺寸或坐标有问题,优先级最高的就是去查XML的size字段和真实图像是否一致。
3.3 训练集与验证集划分:有ImageSets先按它拆,没有就固定随机种子
转换出YOLO标签之后,下一步是划分训练集和验证集。如果数据集的ImageSets/Main里已经有train.txt、val.txt,一定要优先使用,这是标注者经过筛选后的划分,通常更合理。如果数据集没有提供划分文件,就要自己按照比例拆分。
YOLOv8默认期望的数据目录是这种平行结构:
dataset/ ├── images/ │ ├── train/ │ └── val/ └── labels/ ├── train/ └── val/# split_dataset.py import random import shutil from pathlib import Path BASE = Path(".") IMG_SRC = BASE / "JPEGImages" LAB_SRC = BASE / "labels" IMG_OUT = BASE / "images" LAB_OUT = BASE / "labels_final" for sub in ("train", "val"): (IMG_OUT / sub).mkdir(parents=True, exist_ok=True) (LAB_OUT / sub).mkdir(parents=True, exist_ok=True) txt_dir = BASE / "ImageSets" / "Main" if (txt_dir / "train.txt").exists() and (txt_dir / "val.txt").exists(): train_ids = [line.strip() for line in (txt_dir / "train.txt").read_text().splitlines() if line.strip()] val_ids = [line.strip() for line in (txt_dir / "val.txt").read_text().splitlines() if line.strip()] else: # 固定随机种子,保证每次执行结果一样,方便和其他人复现实验 all_ids = [p.stem for p in IMG_SRC.glob("*")] random.Random(122).shuffle(all_ids) split_at = int(len(all_ids) * 0.8) train_ids, val_ids = all_ids[:split_at], all_ids[split_at:] def find_img(img_dir, sid): for ext in (".jpg", ".jpeg", ".png", ".bmp"): p = img_dir / (sid + ext) if p.exists(): return p return None for ids, img_out, lab_out in ( (train_ids, IMG_OUT / "train", LAB_OUT / "train"), (val_ids, IMG_OUT / "val", LAB_OUT / "val"), ): for sid in ids: src_img = find_img(IMG_SRC, sid) src_lab = LAB_SRC / (sid + ".txt") if src_img is None or not src_lab.exists(): print(f"[跳过] {sid} 缺少图像或标签") continue shutil.copy(src_img, img_out / src_img.name) shutil.copy(src_lab, lab_out / (sid + ".txt")) print(f"训练集 {len(train_ids)} 张,验证集 {len(val_ids)} 张")这个脚本的执行逻辑很简单:先尝试读取ImageSets/Main下的划分文件,读不到就走随机划分分支。随机划分时固定随机种子为122,这个数本身没有特殊含义,作用只是让每次执行结果一致,方便记录实验条件。
这里要补充一句:水下图像经常以视频帧的形式出现,相邻帧几乎一模一样。如果视频来源的数据被完全随机打散,同一视频的帧可能一部分进训练集、一部分进验证集,验证指标会虚高。遇到这种情况,划分前应该先按视频片段分组,一组视频的所有帧要么全进训练集,要么全进验证集,不要让同源帧跨集。
3.4 写Ultralytics需要的data.yaml
目录结构准备好之后,训练前还要配置一个data.yaml,告诉YOLO训练器去哪里找图像、有哪些类别。
# data.yaml path: /your/data/root # 数据集根目录的绝对路径 train: images/train val: images/val names: 0: sea_cucumber 1: scallop这里的names顺序必须和转换脚本里的CLASSES顺序完全一致,不能只改名字不改id。path建议写绝对路径,因为相对路径很容易取决于运行训练命令时所在的工作目录,项目换一台机器就找不到数据,调试起来头大。
4. VOC水下数据集避坑指南:翻车点与排查方案
数据集类项目的坑往往不在模型,而在数据和标签本身。下面五条是我在跑水下目标检测时真实遇到过的问题,按现象、原因、解决的顺序整理出来,每条都能直接对着排查。
4.1 类名不统一:同一目标三种写法让转换脚本静默丢框
现象:训练时发现海参的recall很低,回查转换后的txt文件,发现大量图像只有扇贝标签,海参框全部消失。
原因:XML里的name字段五花八门,有的写着sea_cucumber,有的写海参,有的写SeaCucumber。转换脚本遇到不在CLASSES白名单里的名字直接跳过,于是框被静默丢弃。
解决:转换前先统计全部唯一name,把变体归并到标准类别名。统计代码很简单,用Python遍历一遍XML的name字段收集到集合里。之后写一个name映射表,把海参、SeaCucumber、sea_cucumber全部映射到同一个id。千万别在转换脚本里靠大小写处理,因为数据集可能混入中文名,大小写归一化救不回来。
4.2 size字段失真:原图预览正常,一训练框全乱
现象:VOC格式的可视化工具里看标注框位置是对的,但转成YOLO格式之后,用可视化脚本画出框,框和目标错位,甚至跑到图像外面。
原因:数据集预处理阶段对图像做了裁剪或缩放,XML里的size字段没同步更新,转换脚本却拿它当了分母。比如图像实际是1280x720,XML里写1920x1080,归一化结果就整体偏移。
解决:转换脚本一律用PIL读真实图像尺寸,不要信任XML的size字段。体检脚本发现“尺寸不一致”的输出后,批量修正XML里的width、height,让原始标签文件恢复可信,方便后续审计。修正之后再跑转换,框就对了。
4.3 类别极度不均衡:整体mAP能看,扇贝AP只有零点几
现象:训练日志里mAP50达到0.8,但按类别看,海参AP接近0.9,扇贝AP只有0.3。东倒西歪的原因是模型对扇贝的样本严重欠拟合。
原因:水下数据集中海参样本多、扇贝样本少,损失被多数类主导,少数类学不出来。
解决:先看第2章统计脚本的输出确认数量差异。数据层面,可以裁剪小类目标做copy-paste增强,把扇贝实例粘贴到没有目标的背景图上,增加扇贝样本量;也可以对小类图像提高mosaic增强的参与概率,让小类在训练中出现的频率更高。评估层面,必须输出per-class AP,不能只盯整体mAP,因为整体指标会把类别问题平均掉。
4.4 训练与验证数据泄露:验证集mAP虚高,部署就崩
现象:训练完验证时mAP50高得离谱,但把模型部署到新采集的水下视频上,漏检严重。
原因:数据集没有提供划分文件,有人把所有JPEG图像全部塞进训练集,验证时又拿同一批图像做评测。更隐蔽的一种是视频连续帧被随机打散,同源帧同时出现在train和val里。
解决:训练前用集合运算检查两个划分的交集,确认没有重复文件名。对于视频帧数据,划分单位应该是视频片段而不是单帧,同一视频片段只能出现在一个集合中。验证集最好保留一部分完全没有参与过训练的独立视频序列,作为最后的部署模拟测试。
4.5 水下图像颜色失真:模型学的不是形态而是背景
现象:在测试集里放一张色偏完全不同的水下照片,模型把深色背景误检成海参,真正的扇贝反而漏检。
原因:水下数据整体呈现出蓝色调或绿色调,目标与背景的对比度普遍偏低。模型可能把蓝色背景上的深色纹理当成了海参,它学到的是颜色和背景模式,而不是生物形态。
解决:训练阶段加入颜色扰动增强,随机调整亮度、对比度、饱和度,模拟不同水质条件下的水下成像效果。部署时也可以对输入图像做直方图均衡化预处理,减少色偏影响。验证阶段一定要加入不同背景、不同水质的图像做泛化测试,只看训练集同分布的验证集,发现不了这个问题。
5. 喂给YOLOv8训练自己的水下数据集:参数调整与验证
格式转换和划分做完,数据就变成了YOLO能直接吃的样子。这一章讲训练阶段的具体操作,命令和参数表可以照着抄,但改参数之前要先理解每个参数在水下场景里意味着什么。
5.1 用YOLOv8训练自己的数据集:目录、yaml与启动命令
确保目录结构是标准形式:
your_project/ ├── data/ │ ├── images/ │ │ ├── train/ │ │ └── val/ │ ├── labels/ │ │ ├── train/ │ │ └── val/ │ └── data.yaml └── runs/然后用下面的命令启动训练:
yolo detect train \ data=data.yaml \ model=yolov8n.pt \ epochs=150 \ imgsz=640 \ batch=16 \ seed=122 \ device=0这里我建议先用yolov8n做一个baseline,不要一开始就上yolov8x。原因很简单:n版本训练速度快,能快速验证数据链路是否通畅。如果n版本训练出来的指标惨不忍睹,换成x模型大概率也只是把时间和显存耗在错误的数据上。先跑通,再放大。
5.2 训练参数表:imgsz、batch、epochs怎么调
水下海参、扇贝检测有它自己的特点:目标小、背景杂、图像对比度低。参数调整要围绕这些特点来。
| 参数 | 建议值 | 说明 |
|---|---|---|
| imgsz | 640起步,小目标上960或1280 | 水下目标占画面比例小,640下可能只有十几到几十像素,模型难以捕捉细节 |
| batch | 显存允许范围内尽量大 | 两类目标差异小,batch太小训练波动大,16起步,A100上可以32或64 |
| epochs | 150到300 | 数据集不大,太长容易过拟合,配合早停来看 |
| patience | 30到50 | 训练后期指标不再提升时停止,省时间 |
| optimizer | SGD或auto | Adam在小数据集上容易抖动,SGD长稳,可以优先用auto |
| seed | 固定不换 | 同一个seed才能复现不同参数之间的差异 |
如果体检时发现目标的small占比超过一半,优先把imgsz调到960,甚至1280。代价是显存占用和训练时间上升,但对小目标检测的收益通常非常明显。以下命令是完整调整版:
yolo detect train \ data=data.yaml \ model=yolov8m.pt \ imgsz=960 \ batch=8 \ epochs=300 \ optimizer=SGD \ patience=50 \ seed=122 \ device=0batch从16降到8是因为imgsz提高到960后,单张图占用的显存变大,如果GPU显存不够会直接OOM。batch减小后训练波动会变大,所以这里把epochs放宽到300,给模型更多收敛机会。
5.3 验证阶段看什么:per-class AP与PR曲线
训练结束后,运行验证命令:
yolo detect val \ model=runs/detect/train/weights/best.pt \ data=data.yaml验证结果里会输出两类关键指标:mAP50和mAP50-95。对水下生物检测项目,我通常更关注mAP50,因为水下标注框本身的边界就模糊,mAP50-95对框的像素级重合度过于敏感,参考价值有限。
更重要的是看per-class AP。Ultralytics会在验证结果里给出每个类别的AP,网上的教程很少有人提醒这一点。海参和扇贝的AP差值如果超过0.2,说明模型对某一类的拟合明显不足,优先回到第4章的类别不均衡那一节找解法。验证时还要把混淆矩阵打开看,如果扇贝被大量误检成海参,说明两类在视觉特征上有混淆,需要补充难例样本或调整阈值。
6. 数据闭环:半自动标注回流与复验技巧
数据集不是一次性的。水下场景光照条件变化大,不同海域、不同季节拍回来的图像分布差异明显,模型要持续好用,就要不断吸收新数据。
6.1 新数据回流的半自动标注流程
我的习惯是用当前效果最好的模型先做一次预标注,再人工修正。新采集的水下图像放进new_images目录后,运行:
yolo predict \ model=runs/detect/train/weights/best.pt \ source=./new_images \ save_txt=True \ save_conf=True预测输出的是YOLO格式的txt标签,然后我用labelImg打开这些图,把误检的框删掉、漏检的框补上、画歪的框修正。人工修正完成后就是标准的VOC XML,或者可以直接把修正后的txt保留,重新执行第3章的划分脚本并合并进原数据集。这一步比完全人工标注快很多,模型越准,标注成本越低。
6.2 我习惯保留的验证留底与复现检查
数据合并后,训练前做两件事:备份原始数据,记录校验和信息。我吃过一次亏,给新数据集写转换脚本时改了类别顺序,结果之前的最佳模型怎么复现都少几个点,查了大半天才发现是names顺序对不上。后来每次操作数据集之前,我都会先备份目录,存一份校验和:
cp -r data/ data_backup_$(date +%Y%m%d) sha256sum data/*/*.txt > checksums.txt训练前跑一遍校验和对比,确认数据集没有被意外改动,再开始训练。如果两个实验的训练集完全相同,唯一变量才是模型参数或超参数,实验结果才有可比性。希望这些经验能帮你顺利跑通水下海参、扇贝检测这套流程,少踩数据标注的坑。
本文还有配套的精品资源,点击获取