☰
遥感舰船检测数据集工程实践:从格式校验到YOLO训练避坑
2026/10/5 7:59:03 网站建设 项目流程

简介:面向遥感目标检测研究者与算法工程师,这份数据集提供2238幅卫星舰船影像及对应的Pascal VOC与YOLO格式标注,覆盖航母、驱逐舰、潜艇、集装箱船、油轮等17个常见舰船类别。VOC与YOLO双格式可直接适配Faster R-CNN、YOLO系列等主流检测框架,省去标注格式转换环节,便于直接开展模型训练与验证。压缩包大小约109.32MB,内部以VOC格式的XML标注文件为主体(1999个),并配有说明文档,文件总量约2000个,下载和整理都较为轻量。说明文档对标注格式和类别信息做了必要提示,可帮助快速上手;类别划分贴近真实遥感侦察场景,对舰船细粒度识别、目标检测竞赛和课程实验均有实用价值。目前已有835人学习下载,适合需要标准舰船检测数据集的开发者和研究人员。

1. 拿到一份 2238 张的遥感舰船数据集,先别急着开训

卫星遥感图像的舰船检测,听起来是个专门领域,其实落到工程上就是一件事:把港口、近海、岛屿周边那些在光学影像里呈“条状”“梭形”的目标,用检测框从背景里捞出来。这个数据集压缩包体积不大,2238 张图、17 个类别、VOC 和 YOLO 两种标注格式都齐了,属于“规模适中、格式友好”的入门级遥感检测资源。对刚接触遥感目标检测的工程师来说,它解决了最磨人的数据准备问题——不用自己对着图像画框,解压之后就能喂给 YOLO 系列模型跑一轮训练。

但要提醒一句:这个数据量级放在舰船检测里并不算大,单类别的有效样本可能只有几十到几百个,真正决定你训练效果的不是“有没有数据”,而是拿到数据后的一整套校验、划分和训练策略。这篇文章不打算讲空洞的“遥感AI前景”,而是从拿到压缩包后的第一步开始,把格式转换、数据验签、训练配置、踩坑排查和提升精度的思路完整拆开。

2. VOC 与 YOLO 双格式的构成:先搞清楚包里到底是什么

2.1 VOC 格式的目录结构与 XML 标注字段

解压 7z 之后,你会看到一个典型的 VOC 风格目录树。常见做法是保留JPEGImages(原始图片)、Annotations(VOC 格式的 XML 标注)、ImageSets/Main(训练/验证划分的 txt 索引)这三个核心目录。如果作者还同时给出了 YOLO 格式的标签目录,那通常是并列的labels文件夹,每张图对应一个同名的 txt 文件,内容是一行一行的归一化坐标。

VOC 的 XML 标注长这样,字段含义很直接:

<annotation> <folder>JPEGImages</folder> <filename>ship_00123.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>cargo</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>256</xmin> <ymin>180</ymin> <xmax>410</xmax> <ymax>240</ymax> </bndbox> </object> </annotation>

这里<bndbox>里的是绝对像素坐标,<name>对应类别名。这份 XML 是整个数据集最接近“原始标注”的形态,后续如果要做旋转框检测、实例分割或者格式迁移,都得从它重新出发。所以我的建议是:哪怕你只打算用 YOLO 格式训练,也别把 Annotations 删掉。

2.2 YOLO 格式的 txt 标签:每行一个目标,五个数字

YOLO 格式的标签没有 XML 那么复杂,每张图对应一个 txt,每行描述一个目标实例:

3 0.486328 0.312500 0.078906 0.052083 7 0.721094 0.554167 0.037891 0.025000

五个数字依次是:类别 id(从 0 开始)、归一化后的中心点 x、中心点 y、宽度 w、高度 h。归一化指的是用绝对坐标除以图片的宽和高,所以这些值都在 [0, 1] 区间内。如果这个包里的 txt 是由 VOC XML 自动转换的,你需要警惕一件事:坐标四舍五入到小数点后几位。按常见精度(6 位小数)其实够用,但如果转换代码写得粗糙,可能出现宽度为 0 的非法框——这个问题后面避坑章节单独说。

2.3 两种格式并存的原因:不同训练框架的输入习惯不同

作为工程交付,数据集同时提供 VOC 和 YOLO 格式,本质上是降低使用门槛。Ultralytics YOLO 系列、YOLOv5 原版训练脚本、MMYOLO 这些工具读的是 txt 标签;而需要用到torchvision自带的VOCDetectionAPI、或者想跑 MMDetection 的某些检测器时,直接读 VOC XML 会更顺畅。把转换脚本留好,以后自己扩充数据时就能复用这条链路。

# voc_to_yolo.py import xml.etree.ElementTree as ET import os def voc_to_yolo(xml_path, out_txt_path, class_list): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find('size/width').text) img_h = int(root.find('size/height').text) lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in class_list: continue # 跳过未登记类别 cls_id = class_list.index(name) bnd = obj.find('bndbox') xmin = float(bnd.find('xmin').text) ymin = float(bnd.find('ymin').text) xmax = float(bnd.find('xmax').text) ymax = float(bnd.find('ymax').text) # 计算中心点和宽高,注意宽高除以图片尺寸 cx = (xmin + xmax) / 2.0 / img_w cy = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") with open(out_txt_path, 'w') as f: f.write('\n'.join(lines))

这段脚本的核心逻辑是把绝对像素坐标换算成归一化坐标。注意我用cx = (xmin + xmax) / 2.0 / img_w而不是(xmin + xmax) / 2 / img_w,浮点除法和整数除法的区别在 Python 3 里不明显,但刻意写成浮点可以避免脚本被移到 Python 2 环境时翻车。另外class_list必须和训练用的类别清单顺序一致,否则类别 id 会对不上。

3. 数据验签三步走:训练前的家底盘点

3.1 统计图片分辨率与 EXIF 信息

拿到数据集后的第一件事,不是配训练环境,而是把数据集的“家底”摸清楚。我一般会写一个脚本,统计图片的高宽分布、是否有 EXIF 旋转信息、是否有损坏图片。遥感图像数据集里最常见的坑是:图片实际分辨率不是 640x640 的整数倍,模型输入的 reszie 比例一旦没对齐,小目标就会被压没。

# inspect_dataset.py from PIL import Image import os, collections img_dir = 'JPEGImages' size_counter = collections.Counter() sizes = [] for fn in os.listdir(img_dir): if not fn.endswith(('.jpg', '.jpeg', '.png')): continue p = os.path.join(img_dir, fn) try: with Image.open(p) as im: w, h = im.size sizes.append((w, h)) size_counter[(w, h)] += 1 # 读取 EXIF orientation,存在时防止后续训练被自动旋转 exif = im.getexif() if 274 in exif: print(f"{fn}: EXIF orientation = {exif[274]}") except Exception as e: print(f"{fn}: 损坏或无法读取 -> {e}") print("Top 10 分辨率:", size_counter.most_common(10))

这段脚本会告诉你两件事:图片是否有多个来源混入的分辨率(比如 1280x720 和 1024x1024 混在一起),以及有没有损坏文件。EXIF orientation 这类信息如果不检查,训练时图像可能被隐性旋转,导致标签坐标错位——虽然现代深度学习框架的 dataloader 大多会处理,但自己先看到才安心。遥感舰船这种目标对分辨率特别敏感,一张 1280 宽的图上可能存在只有 40x20 像素的小船,输入网络时如果被缩放过,目标信息可能直接丢失。

3.2 标签坐标合法性与类别分布核对

第二步是核对标签坐标是否越界、是否出现负值或超宽高,同时统计类别分布。这个脚本直接读 YOLO 格式 txt,如果谁给你一份标签文件,这个校验必须跑在前面。

# check_labels.py import os label_dir = 'labels' img_w, img_h = 1280, 720 # 先用统一直观分辨率占位,实际应从图片信息读取 class_counter = {} bad_lines = [] for fn in os.listdir(label_dir): if not fn.endswith('.txt'): continue with open(os.path.join(label_dir, fn), 'r') as f: for line in f: parts = line.strip().split() if len(parts) != 5: bad_lines.append((fn, '字段数不为 5')) # 字段数异常 continue cls, cx, cy, w, h = parts cx, cy, w, h = map(float, (cx, cy, w, h)) # 坐标合法性检查 if w <= 0 or h <= 0: bad_lines.append((fn, '宽高小于等于零')) if cx - w/2 < 0 or cx + w/2 > 1.0001: bad_lines.append((fn, 'x 方向越界')) if cy - h/2 < 0 or cy + h/2 > 1.0001: bad_lines.append((fn, 'y 方向越界')) class_counter[int(cls)] = class_counter.get(int(cls), 0) + 1 print("类别分布:", dict(sorted(class_counter.items()))) print("异常行数:", len(bad_lines)) for fn, reason in bad_lines[:20]: print(f" {fn}: {reason}")

这段代码虽然逻辑简单,但能拦住大量“训练到一半 loss 变成 nan”的悲剧。越界坐标会让损失函数计算时出现 log 0,或者让 anchor 匹配到根本不在图内的目标,直接拉垮收敛。如果你的包里有多个不同分辨率图片,那么这里的归一化坐标是按各自图片尺寸计算的,检查时不能用一个固定尺寸,应该逐张读取图片尺寸再对应检查——我上面用占位参数是为了说明思路,实际工程里必须从 JPEGImages 里读每张图的高宽。

3.3 训练集 / 验证集划分的合理性判断

遥感数据集的划分有一个很容易被忽略的问题:附近海域的舰船分布通常是成簇的,同一片港口里可能拍到了几十艘船,如果这些图分别出现在训练集和验证集里,模型等于提前见过了答案。常见的错误做法是完全随机划分,正确做法是按场景块划分——同一张场景延伸出来的裁剪块必须归到同一边。

# split_by_scene.py import os, random, shutil # 假设文件名有场景前缀,如 portA_001.jpg img_files = os.listdir('JPEGImages') scene_groups = {} for fn in img_files: scene_id = fn.split('_')[0] # 按文件名前缀判定场景 scene_groups.setdefault(scene_id, []).append(fn) train_imgs, val_imgs = [], [] for scene_id, imgs in scene_groups.items(): random.shuffle(imgs) split = int(len(imgs) * 0.8) # 每个场景内部再按 8:2 切 train_imgs.extend(imgs[:split]) val_imgs.extend(imgs[split:]) print(f"train={len(train_imgs)}, val={len(val_imgs)}")

这里的关键在于“场景前缀”来源于文件名约定。如果这个数据集的命名本身就是流水号(比如 0001.jpg 到 2238.jpg),那就没法按场景划分,退而求其次的做法是按经纬度或拍摄时间划分——但文件名没有这些信息时,只能依靠现有的ImageSets/Main给定划分,或者按聚类算法对图像特征聚类后分桶。总之,随机划分对遥感场景来说基本就是给自己埋雷。

4. 用 YOLOv8 训练自己的舰船检测模型:最小可跑通配置

4.1 数据目录组织与 YAML 配置

拿到数据之后,最先要做的是按 YOLO 训练工具的习惯重新组织目录。常见做法是建一个主目录,内部按images/train、images/val、labels/train、labels/val分好。YOLOv8 的YOLODataset在读数据时会自动找同级labels目录,所以图片和标签的目录层级必须严格对应。

# ship.yaml path: /data/ship_dataset # 数据集根目录,绝对路径 train: images/train # 相对 path 下的训练图片目录 val: images/val # 相对 path 下的验证图片目录 nc: 17 # 类别数 names: 0: aircraft_carrier 1: amphibious_ship 2: cargo 3: destroyer 4: fishing_boat 5: frigate 6: hovercraft 7: merchant_ship 8: patrol_boat 9: rescue_boat 10: sailboat 11: submarine 12: tanker 13: tug 14: warship 15: yacht 16: oil_platform

names的排列顺序必须和标签 txt 里的类别 id 一一对应,这是 YOLO 系训练最容易出错的点。如果作者的类别清单和你 YAML 里的不一致——比如他把cargo放在 id=2,而你漏看了一个类别——那么训练不会报错,但模型会把货船学成了巡逻艇,验证集 mAP 还会诡异地高,因为 datset 内部是一致的,只有在实际使用时暴露问题。所以拿到数据集后,建议先看一遍类别清单文件,或者自己用脚本把类别 id 和名称打出来确认。

4.2 模型选型与训练命令

舰船检测有一个绕不开的难点:小目标密集排列。同一幅遥感图像里可能停着十几艘并排的船,每艘船在 640x640 的输入下只有 20-50 像素宽。针对这种情况,模型初始权重选择上我一般会用yolov8s而不是yolov8n,理由是 nano 的 backbone 太浅,特征图上的语义信息不够支撑细粒度分类——比如货轮和油轮在缩小的特征图上几乎无法区分。如果显存允许,yolov8m的收益会更明显。

yolo detect train \ data=ship.yaml \ model=yolov8s.pt \ imgsz=1280 \ batch=16 \ epochs=100 \ patience=20 \ project=ship_experiment \ name=run_ship_v1

imgsz=1280是这个任务的胜负手。如果按默认的 640 训练,舰船目标平均只有 30 像素左右,在 640x640 输入下可能缩小到 10 像素级别,检测器基本看不到细节。1280会让显存占用翻倍,所以要配合batch=16来平衡。如果你的 GPU 只有 8GB 显存,一个务实方案是先用imgsz=640跑通流程,确认能收敛后再用更大尺度微调——不建议一上来就追求最优参数,先让流程跑通才是第一优先级。

4.3 训练过程中的关键监控指标

训练时不要只盯 loss 曲线,更重要的两个指标是mAP50和mAP50-95。遥感舰船检测里 mAP50 往往虚高,因为框定位精度要求没那么严格;真正反映定位质量的是 mAP50-95,它对边界框的精准度更敏感。如果训练到 50 轮后 mAP50-95 还在缓慢爬升,说明模型还在学习更精细的定位,此时早停反而会损失性能。

另一个需要盯的是验证集的分类混淆矩阵。舰船类别里最容易混淆的是货船(cargo)和油轮(tanker),它们在光学影像里轮廓接近,都是矩形船体加舰桥。如果训练日志里这两类的 recall 都偏低,就要考虑用类别权重或复制粘贴增强来优先处理尾部类别。

5. 避坑指南:遥感舰船检测数据集的五个经典翻车现场

5.1 解压后图片分辨率被统一缩放

现象:训练前统计图片尺寸,发现所有图片都是 640x640,而 XML 里写的高宽是 1280x720;或者图片源文件本身是 3000x2000,被压缩包作者统一调整过。

原因:数据集作者为了缩小压缩包体积,常见做法是对图片做了等比缩放或中心裁剪。这样会导致标签 txt 里的归一化坐标虽然看起来合法,但实际目标的像素面积变小,可辨识细节丢失。

解决:用上一节inspect_dataset.py先统计真实图片尺寸。如果发现图片被统一缩放,检查一下目标最小尺寸——如果缩小后最小目标还大于 15x15 像素,问题不大;如果小于 10 像素,建议放弃这张图或在增强阶段使用拼图策略(把多个裁剪块拼成大图再训练)。

5.2 类别名里有空格,txt 解析错位

现象:训练启动后日志里出现IndexError: list index out of range或class id 超出 names 范围。

原因:VOC XML 里的<name>如果写成passenger ship或oil platform,转换到 YOLO 格式时如果没做空格替换,标签行的第二个字段会被误判为类别 id。

解决:写脚本把类别清单里的空格统一替换成下划线,同时做一层额外保险:

# fix_class_names.py import os # 读类别清单 with open('classes.txt') as f: classes = [line.strip() for line in f] # 展示所有类别名,人工确认后再替换 print(classes) # 替换文本 for i, name in enumerate(classes): if ' ' in name or '-' in name: classes[i] = name.replace(' ', '_').replace('-', '_') with open('classes_fixed.txt', 'w') as f: f.write('\n'.join(classes))

替换后记得同步修改 YAML 里的names和所有标签 txt 的第一列 id。这个步骤虽然机械,但漏掉一个空格,模型就会把一类目标整体学歪。

5.3 标签坐标越界:归一化坐标超出图片范围

现象:训练正常,loss 下降,但测出来的检测框全部偏到图像边缘,或者某些目标完全检测不到。

原因:YOLO 格式生成时用了中心点加宽高的表示,如果原始 VOC 标注里的xmax略大于图片宽度(标注软件常见问题),转换脚本没做钳制,就会产生cx + w/2 > 1的非法标签。

解决:直接过滤宽高小于 3 像素的实例,这类小目标在多数网络里都是噪声,留着只会干扰 anchor 匹配。同时对越界坐标做钳制到 [0,1] 区间:

def clamp_label(cls, cx, cy, w, h): # 先把中心点和宽高钳制在合法范围 if w <= 0 or h <= 0 or w > 1 or h > 1: return None x1 = max(0, cx - w/2) y1 = max(0, cy - h/2) x2 = min(1, cx + w/2) y2 = min(1, cy + h/2) if x2 - x1 < 0.01 or y2 - y1 < 0.01: return None # 钳制后太小,丢弃 new_cx = (x1 + x2) / 2 new_cy = (y1 + y2) / 2 new_w = x2 - x1 new_h = y2 - y1 return cls, new_cx, new_cy, new_w, new_h

这里的0.01阈值对应输入 1280 分辨率下的约 12 像素,低于这个值的目标本身就没有足够特征。

5.4 训练集和验证集来自同一场景的裁剪块

现象:训练完成时 mAP50 达到 0.93,但一上真实港口图像立刻漏检严重,val 集指标惨不忍睹。

原因:数据集的原始大图可能被切分成多个小 patch,同一个港口码头的不同裁块随机分到了 train 和 val。模型在训练时见过同一片港区,相当于开卷考试。

解决:这种问题靠代码比较难自动识别(除非文件名有明确前缀),最落地的方案是观察验证集 loss 是否明显高于训练集——如果 loss 差距大,划分基本出了问题。重新按场景块划分,确保同一来源的裁块全部归入训练或验证一侧,不要交叉。

5.5 类别不均衡导致尾部类别永远学不会

现象:训练结束后,前几个大类的 AP 都在 0.85 以上,而最后 3 个类别的 AP 只有 0.2-0.4,并且无论怎么增加训练轮次都上不去。

原因:遥感舰船数据集的类别分布天然长尾,比如渔船可能有 800 个实例,而潜艇可能只有 50 个实例。模型在每轮训练中见到潜艇的样本次数太少。

解决:两个常用方案。一是给尾部类别增加采样权重,具体在 YOLO 里表现为队列采样或类别权重参数;二是用复制粘贴增强(Copy-Paste Augmentation),把尾部类别的目标抠出来,随机叠加到同一批图片的其他位置。

# oversample_tail_classes.py import random, shutil, os # 统计类别分布 class_counts = {} for fn in os.listdir('labels'): with open(os.path.join('labels', fn)) as f: for line in f: cls = int(line.split()[0]) class_counts[cls] = class_counts.get(cls, 0) + 1 # 设定一个阈值,低于该值的类别图片整体复制一份加入训练集 threshold = 200 for fn in os.listdir('labels'): with open(os.path.join('labels', fn)) as f: lines = f.readlines() need_dup = any(int(l.split()[0]) < threshold for l in lines) if need_dup: base = fn[:-4] shutil.copy(f'images/{base}.jpg', f'images_extra/{base}_dup.jpg') shutil.copy(f'labels/{base}.txt', f'labels_extra/{base}_dup.txt')

直接把图片复制一份加入训练集,会让模型每轮迭代都多看到这些稀有类别。这种简单过采样虽然不如 SMOTE 精致,但在检测任务里往往最有效,因为上下文是真实的,不像插值合成可能产生伪影。

6. 更进一步的精度提升:旋转框检测与类别细化

如果你已经把 VOC+YOLO 双格式的数据集在 YOLOv8 上训通,并且发现轴对齐的矩形框在密集排列的舰船上表现欠佳——比如相邻两艘船挨得很近,水平框把两艘船框在一起——那下一步值得投入的方向是旋转框检测。遥感舰船很多是长条形且方向任意,轴对齐框的回归损失会被“朝向”这个维度拖累。MMRotate 或者 YOLOv8-OBB 都支持这类训练,你需要从 VOC XML 里读取四个角点坐标而不是 bbox 的矩形信息,数据集本身如果只提供水平框标注,那么需要先做一个“最小外接旋转框”的转换。

另一个值得尝试的精度提升点是类别合并。17 个类别里有些类别之间的视觉差异非常小,比如军舰(warship)和驱逐舰(destroyer)在低分辨率卫星图上几乎无法区分。如果实际业务只关心“商船/渔船/军舰”三大类,可以在训练前用脚本把同类别的 id 映射合并,这会显著提升每个大类的正样本数量,mAP 往往能提升 3-5 个点。

# merge_classes.py import os # 定义合并映射:原类别 id -> 新类别 id merge_map = { 3: 0, # destroyer -> warship 4: 1, # fishing_boat 保留 14: 0, # warship -> warship 2: 2, # cargo 保留 12: 3, # tanker 保留 } def rewrite_label(label_path, new_label_path, merge_map): with open(label_path) as f: lines = f.readlines() with open(new_label_path, 'w') as f: for line in lines: parts = line.strip().split() cls = int(parts[0]) if cls not in merge_map: continue # 类别直接丢弃(如果不需要) new_cls = merge_map[cls] f.write(f"{new_cls} {' '.join(parts[1:])}\n")

对 17 类数据集来说,合并之后通常能压到 8-10 类,训练时的收敛速度和 mAP 都会有可感知的提升。不要觉得这是“作弊”——业务落地时类别粗粒度意味着更高的可解释性和更低的误报率,毕竟遥感影像里“渔船”和“小型巡逻艇”的边界本来就是模糊的。

我现在的习惯是:拿到任何一个新数据集,先写统计脚本跑出类别分布、图片尺寸、label 合法性三张报表,再决定用什么训练策略。盲开训练就是拿自己的时间和 GPU 电费赌运气,数据校验做扎实的工程,翻车概率会小很多。希望这篇笔记能让你少踩几个坑,也希望你手里的模型在港口和近海场景里跑得稳。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询