ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高分遥感船舶检测:VOC数据转YOLO格式训练实战与避坑指南

高分遥感船舶检测:VOC数据转YOLO格式训练实战与避坑指南

简介:目标检测是计算机视觉的核心任务,在遥感影像分析领域尤其重要。无论是船舶识别还是海上目标监测,数据标注格式的规范化都是模型训练的基础。VOC与YOLO作为两种主流标注格式,在转换时需处理类别映射、坐标归一化和边界越界等细节,稍有不慎就会导致训练失败或漏检。针对高分遥感影像中船舶目标尺度小、分布稀疏的特点,工程实践中需配合数据体检、小目标分辨率提升和回显验证等策略。本文以7000张高分遥感船舶数据集为例,系统讲解VOC格式拆解、转YOLO脚本实现及训练避坑流程,帮助开发者快速落地遥感目标检测项目。

1. 高分遥感船舶检测数据集:7000张VOC图能直接训练YOLO,但这三道关必须过

手头突然拿到一批高分遥感船舶影像,多数人的第一反应是直接丢进 YOLO 训练。真正做过目标检测的人都知道,模型反而是最好解决的环节,数据集格式和类别映射才是最容易翻车的地方。这个数据集是标准的 VOC2007 格式,JPG 与 XML 一一对应,影像标称 0.5-0.8 米分辨率,总计 7000 多张训练图片,覆盖油轮、集装箱船、快艇、小游艇、游轮、杂货船六类船舶目标。看起来开箱即用,但要稳定训练出结果,你至少得过三道关:先核对 XML 字段和文件配对是否完整,再解决类别名归一化与边界越界问题,最后还要认真处理遥感小目标带来的尺度陷阱。这篇文章就以这套数据为例,从格式拆解讲到避坑排查,把完整流程走一遍。

2. VOC2007 格式拆解:XML 标签结构、文件配对规则与类别分布统计

2.1 目录组织与 JPG/XML 配对规则

拿到资源后先别急着训练。VOC2007 格式有一套约定俗成的目录结构,常见做法是分成三个目录:JPEGImages放图片、Annotations放 XML 标签、ImageSets/Main放训练验证集划分的 txt 文件。有的发布方会把数据扁平化成单一目录,那就需要自己重建这套结构,否则很多训练框架的 DataLoader 不认。

这套数据的文件名是ship_002822.xml、ship_002825.xml这种编号方式,每一份 XML 必须有同名 JPG 与之配对。第一次使用前要做一次配对检查,别偷懒,直接用 shell 差集就能看出来:

ls Annotations/*.xml | sed 's/\.xml$//' | sort > xml_names.txt ls JPEGImages/*.jpg | sed 's/\.jpg$//' | sort > jpg_names.txt comm -23 xml_names.txt jpg_names.txt comm -13 xml_names.txt jpg_names.txt

第一行输出是“有 XML 没 JPG”的文件,第二行是“有 JPG 没 XML”的文件。正常情况两条命令都无输出,说明配对完整。我在检查类似数据时不止一次发现发布方漏传图片,如果这步不做,训练时可能跑到一半因为找不到图片崩掉,到时候再排查会很被动。

文件配对通过后,下一步是把数据组织成框架默认能读的目录结构。推荐保留原始文件名,不要重命名,因为 XML 内部的filename节点可能与此关联,后面转 YOLO 格式时文件名就是关联键。

2.2 XML 内部结构:size 节点与 object 节点

VOC 标注文件的核心是 XML,解析时只需要关注filename、size和object三段。下面是一段典型结构,删掉了无关节点后长这样:

<annotation> <filename>ship_002822.jpg</filename> <size> <width>2560</width> <height>1440</height> <depth>3</depth> </size> <object> <name>Oil tanker</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>1364</xmin> <ymin>722</ymin> <xmax>1510</xmax> <ymax>790</ymax> </bndbox> </object> </annotation>

size节点里的宽高是转 YOLO 格式时归一化的基准,必须读 XML 里的值而不是读图片本身的尺寸,因为某些平台处理时会重采样图片,两者一旦不一致,转换后的标注框就会整体偏移。bndbox是像素坐标,xmin/ymin/xmax/ymax都是整数,表示左上角和右下角,这个坐标系从 0 开始。difficult为 1 表示目标难以辨认,很多人转格式时直接丢弃该框,但对于船舶这种背景相对简单的场景,建议保留,减少小目标样本流失。

还有一个容易忽视的点:object节点在单个 XML 里可能出现多次。一艘船一个框、多艘船多个框,转换脚本要处理的是“一个 XML 对应多个 object”的情况,不能用单例解析方式。如果只取了第一个find("object"),那同一张图里其它船就全丢了,训练出来漏检会非常明显。

2.3 数据体检脚本:统计类别数量、每图目标数与框面积

训练前值得花五分钟写个统计脚本,把类别数量、单图目标数、标注框面积分布拉出来看一眼。因为类别名以字符串形式写在 XML 中,六类船的真实分布不用全靠肉眼猜,脚本直接给你答案:

import os import glob import xml.etree.ElementTree as ET from collections import Counter ANN_DIR = "Annotations" class_stats = Counter() obj_count_per_image = [] box_areas = [] for xml_file in glob.glob(os.path.join(ANN_DIR, "*.xml")): root = ET.parse(xml_file).getroot() objects = root.findall("object") obj_count_per_image.append(len(objects)) for obj in objects: name = obj.find("name").text.strip() class_stats[name] += 1 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) box_areas.append((xmax - xmin) * (ymax - ymin)) print("类别出现次数:", dict(class_stats)) print("平均每图目标数:", sum(obj_count_per_image) / len(obj_count_per_image)) print("最大目标面积:", max(box_areas), "像素²") print("最小目标面积:", min(box_areas), "像素²") print("面积中位数:", sorted(box_areas)[len(box_areas) // 2], "像素²")

这段脚本的逻辑是遍历Annotations下所有 XML,用findall("object")收集目标,按类别名计数,同时把每个框的面积算出来。关键参数有两个:ANN_DIR要改成你实际解压出的标签目录路径;面积计算是(xmax - xmin) * (ymax - ymin),单位是像素平方,不是真实地物面积。

输出结果里重点关注三个信号:一是某个类别出现次数特别少,比如不足 200 个框,那这个类基本训练不出来,需要做数据增强或者干脆合并;二是面积中位数如果只有几百像素,说明数据集中小目标占比很高;三是平均每图目标数偏低时,负样本(无目标图片)可能偏多,训练时容易把背景误判成船。我把这套统计脚本存放在本地工具目录,之后每个新数据集都先跑一遍,比直接开训练省心得多。

3. VOC 转 YOLO:转换脚本、归一化计算与边界处理

3.1 为什么要转成 YOLO 格式

VOC 的 XML 字段是给人看的,而 YOLO 系列框架训练时读的是 txt 文本。每行五个数字:类别 id、目标中心点 x、中心点 y、宽 w、高 h,其中 x、y、w、h 都除以图片宽高做了归一化,取值在 0 到 1 之间。格式转换不是可选项,是训练的前置条件。

类别 id 是个隐性坑:VOC 里类别是字符串,YOLO 里类别是整数索引。索引顺序必须与模型配置文件里的names列表严格一致。我在前面统计脚本里已经看到了六类船的名称,转换时统一映射成 0 到 5,顺序自己定,但定完之后所有环节都跟着这个顺序走,不要中途改。

3.2 转换脚本完整实现

下面这段脚本把单个 XML 转成对应 txt,路径和类别映射都拆成变量,方便直接套用:

import os import glob import xml.etree.ElementTree as ET CLASSES = [ "Oil tanker", # 0 "Container ship", # 1 "Speedboat", # 2 "Small yacht", # 3 "Cruise ship", # 4 "General Cargo Ship", # 5 ] def voc_to_yolo(xml_path, label_dir): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") img_w = int(size.find("width").text) img_h = int(size.find("height").text) lines = [] for obj in root.findall("object"): raw_name = obj.find("name").text.strip() if raw_name.lower() not in [c.lower() for c in CLASSES]: print(f"未知类别: {raw_name}, 跳过 {xml_path}") continue cls_id = [c.lower() for c in CLASSES].index(raw_name.lower()) 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) xmin = max(0, min(xmin, img_w - 1)) xmax = max(0, min(xmax, img_w - 1)) ymin = max(0, min(ymin, img_h - 1)) ymax = max(0, min(ymax, img_h - 1)) if xmax <= xmin or ymax <= ymin: continue dw = 1.0 / img_w dh = 1.0 / img_h cx = (xmin + xmax) * 0.5 * dw cy = (ymin + ymax) * 0.5 * dh w = (xmax - xmin) * dw h = (ymax - ymin) * dh lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") if not lines: return 0 os.makedirs(label_dir, exist_ok=True) txt_name = os.path.basename(xml_path).replace(".xml", ".txt") with open(os.path.join(label_dir, txt_name), "w") as f: f.write("\n".join(lines)) return len(lines)

批量执行转换:

for xml_file in glob.glob("Annotations/*.xml"): n = voc_to_yolo(xml_file, "labels") if n == 0: print(f"警告:{xml_file} 没有有效标签") print("转换完成")

脚本核心有三处。第一处是类别匹配,先统一转小写再比对,避免 XML 里偶尔出现Oil Tanker与Oil tanker大小写不一致导致类别漏掉;第二处是边界裁剪,把超出图像范围的坐标强制拉回0到img_w - 1或img_h - 1,这是防止后边 Anchor 匹配出负数坐标的关键;第三处是归一化,dw和dh分别用宽高做分母,意味着边框的宽高是相对自身维度的比例,不是相对全图面积。

转换完成后,labels目录下应该是与 XML 同名的 txt 文件,每行五个数字。如果转换过程中某张图没有任何有效框,脚本会打印警告,记录下这些文件名,后面从训练清单里剔除。

3.3 转换后的回显验证

转完格式不等于标注没丢。最高效的验证方式是拿一张原图和它对应的 txt,把归一化坐标反算回像素坐标,然后用 OpenCV 画框。这一步能看到标注框是否真的框住了船,以及坐标经过裁剪和归一化后有没有漂移。

import cv2 def draw_yolo_label(img_path, txt_path, class_names): img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path) as f: for line in f: cls_id, cx, cy, bw, bh = map(float, line.split()) x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) label = class_names[int(cls_id)] cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, label, (x1, max(0, y1 - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) return img img = draw_yolo_label( "JPEGImages/ship_002822.jpg", "labels/ship_002822.txt", CLASSES ) cv2.imwrite("check_ship_002822.jpg", img)

注意这里换算:cx和bw都是相对图片宽度w的比例,所以要分别乘上w和h。画框时左上角是cx - bw / 2,右下角是cx + bw / 2,因为 YOLO 坐标存的是中心点。putText的y坐标做了max(0, y1 - 4)防护,避免文字跑到图片外面。

抽 10 到 20 张图看回显结果,重点关注三类问题:框是否明显偏离船舶主体、框是否超出图像边缘、类别名称是否张冠李戴。这一步虽然耗时,但比训练完发现 mAP 极低再回头找原因划算得多。

4. 训练前的数据划分与均衡:先统计类别和尺度,再决定超参数

4.1 固定随机种子的数据划分脚本

VOC 格式通常自带ImageSets/Main划分文件,但很多发布方只给了 JPEG 和 XML,划分文件缺失。这种时候自己生成train.txt和val.txt,注意必须固定随机种子,保证每次复现跑出来的划分一致,否则论文实验无法对比。

按 9:1 比例划分,代码很简单:

import os import random from glob import glob train_ratio = 0.9 random.seed(42) image_paths = sorted(glob("JPEGImages/*.jpg")) random.shuffle(image_paths) n_train = int(len(image_paths) * train_ratio) train_paths = image_paths[:n_train] val_paths = image_paths[n_train:] with open("train.txt", "w") as f: f.write("\n".join(train_paths)) with open("val.txt", "w") as f: f.write("\n".join(val_paths)) print(f"训练集: {len(train_paths)} 张, 验证集: {len(val_paths)} 张")

关键点在两个:random.seed(42)放在shuffle之前才有效;sorted先排序再打乱,避免不同文件系统下glob返回顺序不一致导致划分漂移。train_ratio = 0.9对 7000 多张图来说验证集约 700 张,足够看趋势。如果做交叉验证,可以把比例调到 0.8,多留一点验证样本。

划分完成后,YOLO 训练时通过train.txt和val.txt各自的图片路径列表加载数据。注意 txt 里写的是绝对路径还是相对路径,要在训练配置里对应好,否则训练时找不到文件。

4.2 类别均衡检查与处理策略

数据划分完之后,马上统计训练集里六个类别的框数分布。如果某个类别框数严重偏少,比如Small yacht只有一两百个,而Oil tanker有两千多个,模型会偏向多数学类别,少数学类别几乎学不出来。

处理办法优先考虑三种。第一种是类别重加权,在损失函数里给少样本类别更高的权重,YOLOv5 系可以修改class_weights或用focal loss的alpha参数;第二种是复制粘贴增强,把少样本类别的目标裁剪出来,旋转缩放后贴到空白海面区域,同时生成新标注;第三种最简单,直接多训练几个 epoch,配合早停观察少样本类别的召回率是否上升。

不建议用简单上下采样来均衡,遥感影像里船舶目标尺寸差异太大,欠采样会丢失大量上下文信息。我给这类数据集做均衡时,习惯先跑 50 个 epoch 看各类别的 AP 曲线,如果差距在 10 个百分点内就不做特殊处理,这是成本最低的路线。

4.3 输入分辨率与小目标策略

这是遥感船舶检测最关键的超参数问题。0.5-0.8 米分辨率意味着什么?一条 30 米长的快艇,在 0.5 米分辨率影像上大约是 60 像素长,在 0.8 米分辨率影像上只有不到 40 像素。而常见训练做法是把图片直接 resize 到 640×640,一张原始分辨率可能 3000×2000 的影像,缩放后小船可能只剩 8 到 12 个像素,几乎无法检测。

我的做法是先把训练输入分辨率提高到 1280。YOLOv5 和 YOLOv8 都支持在配置里指定imgsz=1280,显存允许的话可以上 1536。这会显著提升小目标召回率,代价是训练速度下降,且 batch size 必须相应缩小。举个例子:

# data.yaml train: train.txt val: val.txt nc: 6 names: ['Oil tanker', 'Container ship', 'Speedboat', 'Small yacht', 'Cruise ship', 'General Cargo Ship']
python train.py --img 1280 --batch 16 --epochs 100 --data data.yaml --weights yolov5s.pt

--img 1280是边长,框架会自动按比例缩放并 padding。--batch 16是经验值,在 24G 显存下能跑动,显存小就降到 8,配合--accumulate 2等效回 16。--weights yolov5s.pt选小模型是因为遥感小目标场景下,大模型的感受野不一定更适合小目标,先跑通 baseline 再换大模型。这里还有一个容易被忽略的细节:训练时开了多尺度增强(--multi-scale)后,输入尺寸会在 0.5 到 1.5 倍之间随机伸缩,如果船目标本来就小,建议关闭多尺度,避免小目标被缩得更小。

5. 常见问题与排查:标签越界、类别名不一致、显存爆掉的五个实例

5.1 标签层面的三个翻车现场

现象一:转换脚本跑完后,部分 txt 的行数比 XML 里object节点数少,甚至某些类完全没有出现在标签里。训练时发现某个类别 AP 始终为 0。

原因:XML 里类别名写法不统一。有的写Oil tanker,有的写oil_tanker,还有的写Oil Tanker,直接字符串比对时匹配不上,被脚本当成了“未知类别”跳过。

解决:转换脚本里统一用strip().lower()做归一化,再与CLASSES列表的小写形式比对。转换后按类别统计一次 txt 里的行数分布,如果某类为 0,立刻回头打印原始 XML 里的name字段看看实际写法。

现象二:转换后回显,发现某些框跑到图片外面,或者框的宽高为 0,画出来是一条线。

原因:原始 XML 里的bndbox坐标在人工标注时超出了图片边界,尤其是沿岛岸线停靠的船,标注员会把框一直拖到图片边缘外。直接归一化后cx、cy可能大于 1,训练时 Anchor 匹配逻辑会错乱。

解决:转换时对xmin/ymin/xmax/ymax做一次 clip,限制在0到img_w - 1和0到img_h - 1之间,再计算宽高,宽高小于等于 0 的框直接丢弃。这段逻辑在第三章的脚本里已经内置,新的数据集也要保持这个习惯。

现象三:同一张图里有五艘船,但标注 XML 里只写了三个object,训练后模型在该图上漏检两艘。

原因:这是标注阶段遗漏,不是转换问题。发布方在人工标注时漏标了部分目标,尤其密集港口区域容易出现。

解决:无法靠脚本自动修复,只能先用量化手段找出可疑图片。前面写的统计脚本输出“平均每图目标数”,如果明显偏低,再写个脚本筛出单图目标数少于 2 的 XML,人工抽看。这类问题对训练影响很大,但大多数据集发布方不会逐张复查,使用者必须有心理预期。

5.2 训练与推理层的两个问题

现象四:训练刚开始没多久,Loss 变成nan,或者前期 Loss 正常、到第 20 个 epoch 附近突然跳成nan。

原因:最常见的是学习率过大和梯度爆炸。遥感影像背景单一,Loss 曲面比较平缓,学习率设置过高时容易在收敛后期震荡出nan。另一个原因是用高分辨率输入训练时batch size太小,BN 层的统计量不稳定。

解决:先把--lr降到默认值的一半,例如--lr 0.001调成0.0005。如果还出nan,查一下训练集里是否有全黑或全白的损坏图片,这种异常输入会让 Loss 跳动很大。加一段数据加载时的imread检查,读出来的图shape不对就剔除。

现象五:--img 1280训练一启动就报CUDA out of memory,或者跑几个小时才一个 epoch,训练进度基本没法看。

原因:高分辨率输入直接放大特征图显存占用,batch 16在 1280 分辨率下对显存需求远超预期。很多人的第一反应是换更大的显卡,其实没必要。

解决:先降到--batch 8,再用梯度累积补足有效 batch。YOLOv5 里设置--batch 8 --accumulate 2,效果等效于batch 16,显存开销减半。如果显存仍不足,把输入降到--img 960,对于 0.5-0.8 米分辨率的船舶数据,960 相对 1280 的 mAP 损失通常在 2 个点以内,但训练稳定性提升明显。不要用--img 640,小目标会直接消失。

6. 训练完成后的三个验证动作:回显、TTA 与小目标召回检查

6.1 模型预测结果回显

训练结束后不要只看results.csv里的指标,用训练好的权重对验证集做一次批量推理,把预测框画回原图。重点看验证集里Small yacht和Speedboat这两类,如果小目标基本没框出来,说明前面输入分辨率还是太低,或者数据增强策略有问题。这一步会把指标上隐藏的问题暴露得很直白。

6.2 多尺度推理

推理阶段可以用多尺度测试提升小目标召回。YOLOv5 里--augment开关会做 TTA,综合多个尺度的预测结果再 NMS。对遥感船舶场景,TTA 通常能提升 1 到 3 个 mAP 点,速度影响大约 2 到 3 倍。如果业务要求实时性,TTA 只用于验证集报告,不上线。

python detect.py --weights runs/train/exp/weights/best.pt --img 1280 --source val_images --augment

6.3 小目标回落的专项观察

最后单独计算Small yacht这一类的召回率,很多框架的val.py会输出每个类别的 AP。如果召回率明显低于其它类,下一步最有效的优化是切片推理,把原图切成 512×512 或 640×640 的重叠块,逐个送入模型再合并结果,配合 NMS。对于这套 0.5-0.8 米分辨率的数据,切片推理往往比换更重的骨干网络提升更明显,因为它从根本上解决了小目标在被缩放过程中信息丢失的问题。

从那以后,我每次拿到新的遥感检测数据集,都会强制按这个顺序走一遍:配对检查、类别统计、转格式、回显验证、小目标专项评估。这套流程救过我很多次,希望你也能少走弯路。

这个数据集我放在网盘里,需要的可以直接下载使用。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进