简介:YOLO海洋目标检测数据集面向计算机视觉学习者与目标检测开发者,收集1000张真实海洋场景图片,包含船只、海洋生物等丰富类别,并同步整理VOC、COCO、YOLO三种格式标签,可直接用于YOLO系列模型训练。压缩包共2000个文件,大小约23.38MB,文件以XML标注、TXT标签为主,另有YAML训练配置与Python脚本,覆盖数据标注、配置读取与划分测试全流程。已有415人学习下载,特别适合需要练习海洋目标检测的入门与进阶用户。除数据本身外,还附带Windows/Linux下的YOLO环境搭建教程和训练案例,以及训练集/验证集/测试集划分脚本,帮助减少数据整理时间,让使用者快速进入模型训练与调优阶段。
1. 海洋目标检测数据集值不值得用:1000张图能覆盖哪些任务
做海洋监测、智慧渔业和近岸搜救的工程师,十有八九卡在同一个环节:模型结构不缺、训练框架不缺,缺的是干净可用的标注数据。这个压缩包的标题说得很直接:1000张海洋目标检测图片,同时给 VOC、COCO、YOLO 三种标签格式,还带划分脚本和训练教程。它解决的问题不是“能不能训”,而是“拿到手之后别在格式转换、数据划分上白耗两周”。
这个规模的数据集属于“打基线和练手刚好,直接上生产不够”的中间档。用它跑通迁移学习、验证模型选型、测通部署流程都没问题;但如果你要部署到真实海域监控系统,1000张图只是一个起点,后面还要靠数据增强、二次标注和持续回灌把它滚大。这篇文章会拆清楚三件事:三类标签格式怎么做换算和校验、划分脚本怎么写才不泄漏、YOLO 训练参数怎么设才稳。新手建议按章节顺序做,熟手可以直接跳到第 5 章对照排查。
2. VOC、COCO、YOLO 三种标签格式:坐标哲学与初次校验
2.1 三种格式的差异:从像素坐标到归一化坐标
VOC 格式的核心是每个图片对应一个同名 XML 文件,目标框用xmin、ymin、xmax、ymax四个绝对像素值描述。比如一张 1920×1080 的图上有一个 boat,XML 里长这样:
<annotation> <filename>001.jpg</filename> <size> <width>1920</width> <height>1080</height> </size> <object> <name>boat</name> <bndbox> <xmin>120</xmin> <ymin>300</ymin> <xmax>640</xmax> <ymax>720</ymax> </bndbox> </object> </annotation>COCO 格式则把所有标注汇总到一个 JSON 文件里,bbox用[x, y, width, height]表达,同样是绝对像素。class 不是字符串,而是category_id整数,需要搭配categories数组才能映射回类别名:
{ "images": [ {"id": 1, "file_name": "001.jpg", "width": 1920, "height": 1080} ], "annotations": [ {"id": 1, "image_id": 1, "category_id": 1, "bbox": [120, 300, 520, 420], "area": 218400, "iscrowd": 0} ], "categories": [ {"id": 1, "name": "boat"} ] }YOLO 格式最朴素,每张图对应一个同名 TXT,每一行是一个目标,五个数字依次是class_id、x_center、y_center、width、height,全部做了归一化,取值在 0 到 1 之间:
1 0.333 0.472 0.271 0.389三个格式放在一起对比,差异就非常明显了:
| 格式 | 文件组织 | 坐标表达 | 类别表达 | 典型用途 |
|---|---|---|---|---|
| VOC | 每图一个 XML | 绝对像素(xmin, ymin, xmax, ymax) | name 字符串 | 可视化、标注软件生态 |
| COCO | 全局一个 JSON | 绝对像素(x, y, w, h) | category_id 整数 | 评估指标、跨框架协作 |
| YOLO | 每图一个 TXT | 归一化后(cx, cy, w, h) | 从 0 开始的索引 | YOLO 系列训练直接读取 |
三种格式本身没有优劣,差别在于场景。做迁移学习训练,YOLO 格式最省事;做可视化排查、和其他团队交换标注,VOC 最直观;做统一的评估和指标计算,COCO 最标准。所以这个数据包把三种格式都给了,真正的价值不是让你选一种,而是让你在不同环节用不同格式,不用自己做交叉换算。
2.2 格式转换的三个易错点
从 VOC 或 COCO 转到 YOLO,计算并不复杂,核心公式是把像素坐标换成比例坐标。以 VOC 为例:
x_center = (xmin + xmax) / 2 / image_width y_center = (ymin + ymax) / 2 / image_height width = (xmax - xmin) / image_width height = (ymax - ymin) / image_height公式简单,但真正翻车的地方从来不在公式本身。第一个坑是零宽高框。标注软件里偶尔会出现xmax == xmin或ymax == ymin的废框,转成归一化坐标后宽或高直接变成 0,YOLO 训练时很容易让 loss 变成 NaN。转换脚本里必须加一道过滤,宽或高小于 1 像素的框直接丢弃,同时记录到日志里方便回溯。
第二个坑是归一化后的精度。归一化坐标经常是0.333333这种无限小数,如果只保留三四位小数,小目标框在图像上会有几个像素的偏移。海洋场景里远处的小船可能只占十几个像素,这种量化误差足以让 label 完全错位。我一般保留至少 6 位小数,宁可文件大一点。
第三个坑出现在 COCO 的category_id上。COCO 的类别 ID 通常从 1 开始,而且不一定是连续的 0、1、2;转换成 YOLO 时,必须拿着categories数组做一次映射,让类别编号从 0 开始重新排序。否则你训练配置里的names和 TXT 里的class_id对不上,训练跑完 mAP 永远是 0。这个问题排在所有转换事故的第一名,后面第 5 章会再展开。
2.3 拿到压缩包后先做的一次完整性校验
我拿到任何数据集的第一件事,不是解压后直接开训,而是先跑一个完整性校验脚本。这个脚本能同时查出“标签缺失”“坐标越界”“类别索引越界”“图片读不出来”四类问题。1000 张图的包看起来不大,但漏掉几条坏标签,训练时排查代价远大于现在花几分钟检查:
import os import sys from pathlib import Path from PIL import Image def check_yolo_format(images_dir: str, labels_dir: str, num_classes: int) -> None: img_exts = {'.jpg', '.jpeg', '.png', '.bmp'} bad_files = [] empty_files = [] for img_file in sorted(Path(images_dir).glob('*')): if img_file.suffix.lower() not in img_exts: continue label_file = Path(labels_dir) / f'{img_file.stem}.txt' if not label_file.exists(): bad_files.append(f'{img_file.name}: label missing') continue with open(label_file, 'r', encoding='utf-8') as f: lines = [line.strip() for line in f if line.strip()] if not lines: empty_files.append(img_file.name) for line in lines: parts = line.split() if len(parts) != 5: bad_files.append(f'{img_file.name}: bad line -> {line}') continue cls_id, x_center, y_center, w, h = parts cls_id = int(cls_id) x_center, y_center, w, h = float(x_center), float(y_center), float(w), float(h) if cls_id < 0 or cls_id >= num_classes: bad_files.append(f'{img_file.name}: class {cls_id} out of range') if not (0 <= x_center <= 1 and 0 <= y_center <= 1 and 0 < w <= 1 and 0 < h <= 1): bad_files.append(f'{img_file.name}: bbox out of range -> {line}') try: with Image.open(img_file) as im: im.verify() except Exception: bad_files.append(f'{img_file.name}: image corrupt') print(f'checked {len(list(Path(images_dir).glob("*")))} files') print(f'empty labels: {len(empty_files)} -> {empty_files[:5]}') if bad_files: print('problem list:') for item in bad_files[:50]: print(' ', item) sys.exit(1) print('all labels look good') if __name__ == '__main__': check_yolo_format(images_dir='data/images', labels_dir='data/labels', num_classes=4)这个脚本的逻辑分三步:先图片找标签,再逐行解析标签内容,最后用 PIL 验证图片是否完整可读。注意num_classes必须和你训练配置里的nc一致,这是检查类别索引越界的唯一依据。运行前把images_dir和labels_dir改成解压后的实际路径。如果脚本输出all labels look good,说明格式层面没问题,可以放心进下一步。
3. 用划分脚本把数据集切成训练/验证/测试集:三个原则和一个脚本
3.1 为什么不要手动拖文件夹
1000 张图听起来不多,很多人图省事,直接在文件管理器里手动分文件夹。这个做法在常规数据集上勉强能用,在海洋目标检测数据集上大概率翻车,原因是海洋监控数据通常来自视频抽帧。
假设一段 10 分钟的航拍视频按每秒 1 帧抽帧,得到 600 张图片,前后相邻几十帧画面几乎是同一艘船、同一片海。手动划分时你看到的只是文件名,根本认不出哪些是同一段视频里的连续帧,于是训练集和验证集里都混进了画面高度相似的图。训练时模型相当于“提前见过”验证集的画面,mAP 虚高。等模型换个水域上线,真实性能暴跌几个点,这就是典型的数据泄漏。
划分要遵守三个原则。第一个是按源分组:如果文件名里有视频 ID 或拍摄批次信息,划分时必须以整个源为最小单位,而不是单张图。第二个是固定随机种子:每次划分结果可复现,后续排查问题能知道每一张图到底去了哪个集合。第三个是分层抽样:保证每个集合里各类别的样本比例和全量数据接近,避免某个类别全跑到测试集里,训练时根本没见过。
比例上我一般用 7:2:1,也就是 70% 训练、20% 验证、10% 测试。测试集只在最终评估时用一次,调参过程只碰训练集和验证集。1000 张图按这个比例切,测试集 100 张,作为最终性能参考足够。
3.2 一个可直接改用的划分脚本
下面的脚本按“组”进行划分,组内图片看作不可分割的整体,从根本上杜绝同源帧泄漏:
import argparse import random import shutil from collections import defaultdict from pathlib import Path def parse_args(): parser = argparse.ArgumentParser(description='group-aware dataset split') parser.add_argument('--src_dir', type=str, required=True, help='data dir containing images and labels') parser.add_argument('--out_dir', type=str, required=True, help='output dir') parser.add_argument('--ratios', type=float, nargs=3, default=[0.7, 0.2, 0.1], help='train/val/test ratios') parser.add_argument('--seed', type=int, default=42, help='random seed') return parser.parse_args() def group_key(filename: str) -> str: # 按文件名里的前缀分组,例如 'video01_frame001.jpg' 属于组 'video01' # 如果文件名没有明确分组前缀,就按连续帧的帧号区间分桶 parts = filename.split('_') if len(parts) >= 2 and parts[0].startswith('video'): return parts[0] # fallback: 直接把整张图看作独立组,只做纯随机划分 return f'img_{filename}' def main(): args = parse_args() random.seed(args.seed) src_images = Path(args.src_dir) / 'images' src_labels = Path(args.src_dir) / 'labels' assert src_images.exists() and src_labels.exists(), 'src dir must contain images/ and labels/' img_files = sorted(src_images.glob('*')) groups = defaultdict(list) for img_file in img_files: if img_file.suffix.lower() not in {'.jpg', '.jpeg', '.png'}: continue key = group_key(img_file.name) groups[key].append(img_file.name) group_names = list(groups.keys()) random.shuffle(group_names) total_groups = len(group_names) n_train = int(total_groups * args.ratios[0]) n_val = int(total_groups * args.ratios[1]) split_map = {} # group_name -> split_name for i, group in enumerate(group_names): if i < n_train: split_map[group] = 'train' elif i < n_train + n_val: split_map[group] = 'val' else: split_map[group] = 'test' for split_name in ['train', 'val', 'test']: (Path(args.out_dir) / 'images' / split_name).mkdir(parents=True, exist_ok=True) (Path(args.out_dir) / 'labels' / split_name).mkdir(parents=True, exist_ok=True) for group, files in groups.items(): split_name = split_map[group] for fname in files: src_img = src_images / fname src_lbl = src_labels / f'{Path(fname).stem}.txt' shutil.copy2(src_img, Path(args.out_dir) / 'images' / split_name / fname) if src_lbl.exists(): shutil.copy2(src_lbl, Path(args.out_dir) / 'labels' / split_name / f'{Path(fname).stem}.txt') print(f'train groups: {n_train}, val groups: {n_val}, test groups: {total_groups - n_train - n_val}') print(f'output written to {args.out_dir}') if __name__ == '__main__': main()运行方式很简单:
python split_dataset.py --src_dir ./ocean_data --out_dir ./ocean_data_split --ratios 0.7 0.2 0.1 --seed 42这段脚本的关键在group_key函数。它按文件名前缀把图片归组,video01_frame001.jpg这种命名会被归到video01组,于是同一条视频的帧永远进同一个集合。如果你的文件名不是这种结构,把group_key换成 ID 命名规则即可。--seed 42保证任何一次运行都能复现同样的划分结果,这是后续排错的基础。复制文件而不是移动文件,原始压缩包和解压目录保持不动,随时可以重新划分。
3.3 划分后必做的三分钟验证
划分完成不等于划分正确。我每次都会花几分钟做三件事:数文件、看类别分布、查跨集相似样本。数文件最直接,用命令行统计各集合图片数:
for split in train val test; do echo "$split images: $(find ocean_data_split/images/$split -type f | wc -l)" echo "$split labels: $(find ocean_data_split/labels/$split -type f | wc -l)" done如果某集合图片数和标签数对不齐,大概率是源数据本身缺标签,需要回到数据包重新做校验。类别分布我习惯统计每个集合里的标注框数量,手头没现成脚本的话,一个快速方法是用 Python 读一遍所有 TXT,对class_id计数。重点看训练集和验证集的比例是否接近,如果一个类在训练集有 80% 的框、验证集只有 5%,后续训练会出偏向。
最后查一下是否有同名但内容完全相同的图片跨集合出现。视频抽帧本来就会产生大量高度相似帧,划分脚本按组切割已经规避了绝大多数泄漏。检查时可以用图片的 MD5 值做精确查重,重复图片如果出现在不同集合,说明分组逻辑里漏了某些命名规则,必须修group_key后重新划分。
4. 把训练教程跑起来:从目录结构到盯 loss 曲线
4.1 训练前准备:目录、data.yaml、预训练权重
用 YOLO 官方生态训练,目录结构最常见的组织方式如下,images放图片,labels放 YOLO 格式 txt,训练集和验证集各自一个子目录:
ocean_data/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ └── labels/ ├── train/ ├── val/ └── test/接着写data.yaml,这是训练时要传入的核心配置文件:
path: /absolute/path/to/ocean_data train: images/train val: images/val test: images/test nc: 4 names: ['boat', 'swimmer', 'buoy', 'fish']这里唯一不能出错的就是names的顺序。YOLO 训练时会直接把 TXT 里的class_id映射到names的索引位置,names[0]对应 0 号类别。如果转换标签时用了另一套类别顺序,这里必须完全一致,否则训练过程毫无报错,mAP 却始终上不去。
预训练权重选择上,1000 张图这个规模我建议优先用yolov8n或yolov8s。n 和 s 参数量小,在小数据集上不容易过拟合,训练速度快,迭代试错成本低。这些权重通常是从大规模公开数据集上预训练得到的,哪怕海洋目标和自然图像差异不小,前几层的基础纹理、边缘特征依然能直接复用。
4.2 训练命令和参数的取舍
两类常见的入口都兼容上面的目录和配置。用 YOLO 命令行 API 是最省事的方式:
yolo detect train \ data=ocean.yaml \ model=yolov8s.pt \ epochs=100 \ imgsz=640 \ batch=16 \ patience=20 \ project=ocean_exp \ name=baseline如果用的是 YOLOv5 分支,对应命令是:
python train.py \ --data ocean.yaml \ --weights yolov5s.pt \ --batch-size 16 \ --epochs 100 \ --img 640 \ --patience 20没必要纠结用哪个分支,两者训练出来的模型结构和推理方式很接近,选你更熟悉的就行。几个关键参数值得仔细权衡:
| 参数 | 建议值 | 说明 |
|---|---|---|
| imgsz | 640 | 海洋小目标多,显存够用就上 1280;先 640 跑通再调整 |
| batch | 16 | 受显存限制,用 n 系列模型可以尝试 32 |
| epochs | 100 | 1000 张图不需要太长,早停开启后通常 70 轮收敛 |
| patience | 20 | 验证集指标连续 20 轮不提升就停止 |
| cache | True | 小数据集完全能放进内存,明显加快训练 |
| workers | CPU 核数的一半 | 过高的 IO 线程反而拖慢训练 |
不要想着一步到位。我一般先用imgsz=416和epochs=30跑一版,确认数据链路、标签映射、loss 下降都没问题,再上完整参数。这个方法能区分“代码配置问题”和“模型效果问题”,省下的调试时间比多跑一次训练的时间划算得多。
4.3 训练日志要盯的三个信号
训练启动后,不需要一直盯着终端,但每轮日志里这三项必须关注。
第一是各种 loss 值。box_loss和cls_loss在前 20 个 epoch 内应当稳步下降,如果有某个 loss 降不下去甚至升高,往往指向标签错误,尤其是 0 宽高框或者坐标越界框。第二是mAP_0.5,它应该从某个小值开始爬升,如果一直停在 0 且 loss 看着正常,回到第 2 章检查类别索引。第三是精确率和召回率的关系,海洋搜救场景对召回率更敏感,漏一个人远比误检一个浮标严重,所以recall比precision更值得盯。
训练结束或者中途停住后,用验证集跑一次推理看具体效果:
yolo detect val \ data=ocean.yaml \ model=ocean_exp/baseline/weights/best.pt \ imgsz=640这里best.pt是训练过程中验证集指标最好的权重。输出会包含每类别的 mAP 和 PR 曲线,保存成图片可以直接看。如果单看曲线不好定位问题,把几张典型图片的预测框画出来对照原图,错到哪一目了然。
如果训练中途断电或想接着跑,不需要从头开始。YOLO 命令行 API 直接加resume=True,v5 分支加--resume,会自动从上次保存的 checkpoint 继续。这个功能在长训练中非常实用。
5. 海洋目标检测的典型翻车点:从标签错位到小目标漏检
5.1 训练没报错但 mAP 是 0:先查标签不要查模型
现象:训练过程完全正常,loss 在下降,日志也没报错,但每个类别的 mAP 全是 0,验证集预测框全部偏到画面边缘或直接消失。
原因:九成是类别索引错位。数据集转成 YOLO 格式时,TXT 里的class_id是按某个自定义顺序写的,但data.yaml里的names用的是另一套顺序。比如转换脚本里 boat 是 1,swimmer 是 2,训练配置里names[1]却是 swimmer,模型永远在用错误的映射学习,训练目标自相矛盾。
解决:打开任意一个 TXT,遍历所有标签确认class_id最大值小于nc,然后手动检查data.yaml的names顺序,和标签生成时的种类顺序做逐一比对。更稳的办法是写一个映射文件固定类别顺序,转换脚本和训练配置都从同一个文件读取,从源头杜绝两边不一致。
5.2 验证集 loss 高位震荡:同源帧污染了划分
现象:训练集 loss 降得很顺利,验证集 loss 却大幅波动,验证集 mAP 频繁异常跳动。即使增大训练集也没改善。
原因:第 3 章说过的同源帧泄漏。海洋目标检测数据大量来自视频抽帧,相邻几十帧内容高度相似。如果划分时按单张图随机切分,同一段视频的画面同时出现在训练集和验证集,模型在验证集上表现虚高,换个拍摄场景立刻原形毕露。
解决:按视频源分组划分,而不是按单张图。如果文件名里没有标注视频 ID,可以用时间戳或者帧号推测连续区间,把连续帧序列整体分到同一个集合。已经按错误方式划分的,重新跑一次划分脚本,不要想着手工纠偏。
5.3 远处的船和游泳者几乎全部漏检:小目标问题
现象:大目标的检测效果尚可,但远处的小船、露出水面的人头几乎全漏,mAP_0.5勉强能看,mAP_0.5:0.95低得离谱。
原因:海洋场景里小目标占比天然高,远处的船在整张图里可能只占几十个像素。网络经过多次下采样后,这些小目标在深层特征图里只剩几个像素的信息,特征丢失严重。模型本身没问题,是输入分辨率和目标尺度不匹配。
解决:首选直接加大imgsz到 1280,显存不够时用 SAHI 一类的切片推理,把大图切成 tile 分别检测再合并结果。训练时开启多尺度训练,让模型在训练过程中看到不同分辨率的输入。不要指望盲目换大模型来解决,大模型参数量更大,小数据集上更容易过拟合,小目标问题反而加重。
5.4 模型只认船不认游泳者和浮标:类别不平衡
现象:训练结束后 boat 类别的 mAP 很高,swimmer 和 buoy 却几乎不输出,推理时所有框都落在船上。检视数据发现 boat 的标注框数量远多于其他类别。
原因:类别样本数量差距过大时,总 loss 被大类别主导,小类别学不出来。检测框架默认对所有类别平等看待,但实际分布并不平等。
解决:最简单的路径是给少数类增加副本,对少数类样本做水平翻转、亮度扰动等增强后补进训练集;进阶做法是修改cls_loss的类别权重,让类别权重和样本数量成反比。推理阶段也可以对少数类别单独降低 confidence 阈值,比如 boat 用 0.25、swimmer 用 0.1,平衡采回率。这个办法不改训练,立竿见影。
6. 把 1000 张图的价值榨干:迁移学习、数据增强与二次标注的取舍
小数据集的第一铁律:不要从零训练。我拿到这个包后的习惯是直接加载预训练权重,前 30 个 epoch 冻结 backbone 前两阶段,只训练检测头,等验证集 loss 不再下降再解冻全部参数微调。海洋目标里的船、岸线、浮标,其底层纹理和公开数据集中的物体特征有很强共性,这部分不必重新学,把训练资源集中在任务相关的特征上收益更高。
数据增强要按类别精细调,不能一把梭全开。对海洋场景我常用的配置是mosaic开、scale上下限收紧到 0.8 到 1.2,防止目标被缩放得过了头;hsv亮度扰动给 0.2 就够,海天背景本身光照变化不大,扰动过强反而让模型学到错误的颜色不变性。fliplr一定要开,船的朝向在检测任务里没有语义信息,水平翻转等于让有限样本直接翻倍。不要开那些会把小目标强行缩没的增强,例如大幅的 random resize,海洋场景的小目标本来就稀缺,增强后目标消失等于白费样本。
二次标注是扩大数据集性价比最高的手段。用当前模型对一批没有标注的海域截图做预测,把置信度 0.1 到 0.5 之间的检测结果导出成候选框,再用标注工具人工确认,只挑模型最容易漏的硬样本补回训练集。100 张高质量硬样本的收益通常超过随机新标 500 张。这个流程可以滚动执行:每补一批就重训一次,再更新一次候选框,几次迭代后模型在陌生水域的表现提升非常明显。
最后保留一个必须坚持的验证习惯:留出一组“陌生水域”截图,从训练和验证环节里彻底隔离,只在最终评估时使用。模型在训练集和验证集上的分数再漂亮,这组铁律测试过不了就不部署。这个习惯帮我拦住了好几次“本地跑得好、上线就崩”的发布事故。我现在拿到任何数据包,第一件事都是跑格式校验和划分检查,确认无误再点火训练,这习惯帮我省掉了太多白跑的 epoch。希望你能绕开这些坑,更快把这 1000 张图变成能用的模型。
本文还有配套的精品资源,点击获取