1. 遥感旋转框检测的数据困局与破局思路
搞遥感目标检测的朋友大概率都经历过这样一个阶段:兴冲冲下载了DOTA数据集,打开标注文件一看,坐标是x1 y1 x2 y2 x3 y3 x4 y4这种四点多边形格式,每个目标还带一个imagesource和difficult标记。然后你转头看看手头的YOLO训练脚本,它要的是class x_center y_center width height这种归一化后的水平框。两边根本对不上,直接开训就是一堆报错或者模型完全学不到东西。
这个问题的本质在于:遥感图像里的目标几乎都是任意朝向的。航拍视角下,一架飞机可能斜着停在跑道上,一艘船可能以任意角度航行,一个储油罐从俯视图看是圆形但标注时可能用旋转矩形框住。如果你强行用水平框去套这些目标,框里会混入大量背景像素,模型学到的特征被严重污染。举个例子,一个45度倾斜的飞机,它的水平外接框面积可能是真实旋转框的1.5到2倍,多出来的区域全是跑道或者草地。这就是为什么做遥感检测不能简单套用自然图像那一套。
那为什么还要转成YOLO格式?直接上MMRotate或者别的旋转框检测框架不行吗?行,但有几个现实问题:第一,YOLO系列(尤其是v5、v8、v11这些)的工程化程度极高,部署链路成熟,从训练到ONNX导出到TensorRT加速到边缘设备落地,整套工具链非常完善;第二,很多实际项目对精度的要求没有学术 benchmark 那么苛刻,用旋转框转水平框加上一些后处理策略,完全能满足业务需求;第三,YOLO的训练速度快、显存占用低,在同等硬件条件下能迭代更多轮次。所以“旋转框转YOLO格式”这个需求在工程实践中非常普遍。
但这里有个关键决策点:你是要做旋转框检测还是水平框检测?如果你的业务场景里目标朝向对结果影响很大(比如港口船只检测,需要知道船头朝向),那转YOLO水平框就是自废武功,应该老老实实上MMRotate或者YOLO的OBB版本。如果你只是要检测“有没有飞机”“有没有油罐”,不关心朝向,那转水平框完全够用,而且训练和部署都更简单。我个人的经验是:先明确业务需求,再决定标注格式,不要为了用某个框架而强行转换。
接下来我会从DOTA数据集的原始格式讲起,一步步拆解坐标转换的数学原理、代码实现、可视化验证、以及训练时需要注意的坑。整个过程我会用Python实现,不依赖MMRotate的重型依赖,核心逻辑用numpy和opencv就能搞定。你跟着走一遍,基本能掌握遥感数据格式转换的通用方法论。
1.1 DOTA数据集到底长什么样
DOTA(Dataset for Object deTection in Aerial images)是遥感检测领域最常用的公开数据集之一,由武汉大学发布。它的标注格式和COCO、VOC都不一样,采用的是四点标注法。每个目标用四个点的坐标表示,按顺时针或逆时针顺序排列,形成一个任意四边形。标注文件是纯文本,每行格式如下:
x1 y1 x2 y2 x3 y3 x4 y4 category difficult其中x1 y1到x4 y4是四个角点的像素坐标,category是类别名称(如plane、ship、storage-tank等),difficult是0或1,表示该目标是否难以检测(通常忽略difficult=1的样本)。DOTA-v1.0有15个类别,v1.5和v2.0类别更多,这里不展开。
关键点在于:这四个点不一定构成矩形。虽然大多数标注是矩形,但理论上可以是任意四边形。这就给转换带来了一个核心问题:如何从一个任意四边形得到一个“最优”的水平外接矩形?最直接的做法是取四个点的x坐标最小值和最大值作为矩形的左右边界,y坐标最小值和最大值作为上下边界。这样得到的水平框一定能包住原始旋转框,但会引入额外的背景区域。
我实测过,对于长宽比很大的目标(比如桥梁),这种外接框会非常“胖”,背景占比可能超过60%。这时候你可能需要考虑:是不是应该把长条形目标拆分成多个小段来标注?或者直接用旋转框检测?这个后面会详细讨论。
另外DOTA数据集还有一个特点:图像尺寸非常大。原始图像通常是4000x4000甚至更大,直接训练不现实。所以官方提供了切图工具,把大图裁成1024x1024或者800x800的小图,同时更新标注坐标。如果你下载的是已经切好的版本,标注文件里的坐标就是相对于小图的,直接用就行。如果你拿到的是原始大图,需要先切图再转换,否则YOLO的输入尺寸根本放不下。
1.2 为什么不能直接拿DOTA标注训练YOLO
这个问题看起来简单,但背后涉及几个层面的不兼容。首先是格式层面:YOLO要的是归一化的中心点坐标和宽高,DOTA给的是绝对像素坐标的四个角点,两者之间需要做坐标变换和归一化。其次是语义层面:DOTA的旋转框包含朝向信息,YOLO的水平框丢失了这个信息,这是一个不可逆的信息损失。最后是训练层面:YOLO的损失函数(比如CIoU、DIoU)是基于水平框设计的,直接拿旋转框的四个点去算IoU会非常复杂,而且YOLO的anchor机制也是为水平框设计的。
所以转换的本质是:在保留检测能力的前提下,尽可能减少信息损失。具体来说,我们需要做三件事:第一,把四点坐标转成水平外接矩形;第二,把绝对坐标归一化到0-1之间;第三,把类别名称映射成数字索引。这三步做完,YOLO才能正常读取。
但这里有个隐藏的坑:DOTA的类别名称和YOLO的类别索引需要一一对应。如果你自己写转换脚本,一定要确保类别映射表在训练和推理时保持一致。我见过有人训练时用的是{'plane': 0, 'ship': 1},推理时忘了改,结果把所有飞机都识别成船。这种低级错误在工程中并不少见,建议把类别映射写成一个独立的配置文件,训练和推理都从同一个文件读取。
2. 坐标转换的数学原理与代码实现
坐标转换听起来简单,但实际写代码时会遇到各种边界情况。比如四个点不构成凸四边形怎么办?坐标超出图像边界怎么办?归一化时除以的宽高是原始图像尺寸还是网络输入尺寸?这些问题如果不处理好,训练时就会出现loss震荡或者模型完全不收敛。下面我逐个拆解。
2.1 从四点到水平框的几何推导
假设DOTA标注的四个点为(x1,y1), (x2,y2), (x3,y3), (x4,y4),我们要得到一个水平矩形(x_min, y_min, x_max, y_max)。最直接的方法是:
x_min = min(x1, x2, x3, x4) x_max = max(x1, x2, x3, x4) y_min = min(y1, y2, y3, y4) y_max = max(y1, y2, y3, y4)然后中心点和宽高为:
x_center = (x_min + x_max) / 2 y_center = (y_min + y_max) / 2 width = x_max - x_min height = y_max - y_min最后归一化:
x_center_norm = x_center / img_width y_center_norm = y_center / img_height width_norm = width / img_width height_norm = height / img_height这就是YOLO需要的格式。看起来很简单对吧?但这里有几个细节需要注意。
第一个细节:坐标是否包含边界?如果x_max等于图像宽度,那么width_norm就是1.0,YOLO在计算损失时可能会出问题。通常建议把坐标裁剪到[0, img_width-1]和[0, img_height-1]范围内。我一般会在转换前加一行裁剪代码:
x_coords = [max(0, min(x, img_width - 1)) for x in x_coords] y_coords = [max(0, min(y, img_height - 1)) for y in y_coords]第二个细节:宽高为0的情况。如果四个点退化成一条线(比如标注错误),width或height可能为0。这种样本必须过滤掉,否则YOLO计算IoU时会除以0。我通常设置一个最小阈值,比如width < 2 or height < 2就丢弃。
第三个细节:归一化基准。如果你用的是切图后的DOTA,图像尺寸是1024x1024,那归一化就除以1024。但如果你在训练时把图像resize到了640x640,那归一化应该除以640还是1024?答案是:标注归一化应该基于原始图像尺寸,而不是网络输入尺寸。因为YOLO在训练时会自己把图像resize到网络输入尺寸,同时也会相应地缩放标注。如果你提前用640归一化,YOLO再缩放一次,坐标就全乱了。这一点很多新手会搞错。
2.2 完整转换脚本与逐行解析
下面是我在实际项目中反复使用的一个转换脚本,核心逻辑用numpy实现,不依赖任何重型框架。你可以直接复制到自己的项目里用。
import os import numpy as np from pathlib import Path # DOTA类别列表,根据你的数据集版本调整 DOTA_CLASSES = [ 'plane', 'ship', 'storage-tank', 'baseball-diamond', 'tennis-court', 'basketball-court', 'ground-track-field', 'harbor', 'bridge', 'large-vehicle', 'small-vehicle', 'helicopter', 'roundabout', 'soccer-ball-field', 'swimming-pool' ] def dota_to_yolo(dota_line, img_width, img_height, class_map): """ 将一行DOTA标注转换为YOLO格式 返回: (class_id, x_center, y_center, width, height) 或 None """ parts = dota_line.strip().split() if len(parts) < 9: return None # 解析四个角点坐标 coords = list(map(float, parts[:8])) x_coords = coords[0::2] # x1, x2, x3, x4 y_coords = coords[1::2] # y1, y2, y3, y4 category = parts[8] difficult = int(parts[9]) if len(parts) > 9 else 0 # 跳过difficult样本 if difficult == 1: return None # 跳过不在类别列表中的目标 if category not in class_map: return None # 裁剪坐标到图像范围内 x_coords = [max(0, min(x, img_width - 1)) for x in x_coords] y_coords = [max(0, min(y, img_height - 1)) for y in y_coords] # 计算水平外接矩形 x_min, x_max = min(x_coords), max(x_coords) y_min, y_max = min(y_coords), max(y_coords) # 计算中心点和宽高 x_center = (x_min + x_max) / 2.0 y_center = (y_min + y_max) / 2.0 width = x_max - x_min height = y_max - y_min # 过滤无效框 if width < 2 or height < 2: return None # 归一化 x_center_norm = x_center / img_width y_center_norm = y_center / img_height width_norm = width / img_width height_norm = height / img_height # 再次检查归一化后的值是否在合理范围内 if not (0 <= x_center_norm <= 1 and 0 <= y_center_norm <= 1): return None if not (0 < width_norm <= 1 and 0 < height_norm <= 1): return None class_id = class_map[category] return (class_id, x_center_norm, y_center_norm, width_norm, height_norm) def convert_dota_folder(dota_dir, output_dir, img_width=1024, img_height=1024): """ 批量转换DOTA标注文件夹 dota_dir: 包含labelTxt文件夹的目录 output_dir: 输出YOLO标签的目录 """ class_map = {name: idx for idx, name in enumerate(DOTA_CLASSES)} label_dir = Path(dota_dir) / 'labelTxt' output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) total_files = 0 total_objects = 0 skipped_objects = 0 for label_file in label_dir.glob('*.txt'): yolo_lines = [] with open(label_file, 'r') as f: for line in f: result = dota_to_yolo(line, img_width, img_height, class_map) if result is not None: class_id, xc, yc, w, h = result yolo_lines.append(f"{class_id} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}") total_objects += 1 else: skipped_objects += 1 # 写入YOLO格式标签文件 output_file = output_dir / label_file.name with open(output_file, 'w') as f: f.write('\n'.join(yolo_lines)) total_files += 1 print(f"转换完成: {total_files} 个文件, {total_objects} 个目标, 跳过 {skipped_objects} 个")这段代码有几个设计决策值得说明。第一,为什么用min/max而不是cv2.minAreaRect?cv2.minAreaRect会返回最小面积旋转矩形,但它的角度范围是[-90, 0),而且对于接近正方形的目标,角度可能不稳定。用min/max得到的是轴对齐外接矩形,虽然面积更大,但计算简单、结果稳定,而且YOLO本来就是要水平框,所以没必要用minAreaRect。第二,为什么设置width < 2的阈值?这是经验值。遥感图像里有些小目标(比如车辆)可能只有几个像素宽,如果阈值设得太高会漏掉真实目标,设得太低又会引入噪声。2像素是一个比较平衡的值,你可以根据自己数据集的统计分布调整。第三,为什么归一化后还要再检查一次?因为浮点数计算可能有精度问题,比如x_center_norm算出来是1.0000001,这种边界情况会导致YOLO报错,所以加一层保险。
2.3 可视化验证:别等到训练才发现转错了
代码写完不代表转换正确。我强烈建议在训练前做一次可视化验证,把转换后的YOLO框画回原图上,肉眼检查是否对齐。这一步花不了几分钟,但能帮你避免几个小时的无效训练。
import cv2 import numpy as np def visualize_yolo_label(img_path, label_path, class_names, output_path): """ 将YOLO格式标签画到图像上,用于验证转换正确性 """ img = cv2.imread(img_path) h, w = img.shape[:2] with open(label_path, 'r') as f: lines = f.readlines() for line in lines: parts = line.strip().split() if len(parts) != 5: continue class_id = int(parts[0]) xc, yc, bw, bh = map(float, parts[1:]) # 反归一化 xc *= w yc *= h bw *= w bh *= h # 计算左上角和右下角 x1 = int(xc - bw / 2) y1 = int(yc - bh / 2) x2 = int(xc + bw / 2) y2 = int(yc + bh / 2) # 画框 color = (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) # 写类别名 label = class_names[class_id] if class_id < len(class_names) else str(class_id) cv2.putText(img, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) cv2.imwrite(output_path, img) print(f"可视化结果已保存到 {output_path}")跑完这个脚本,打开输出图像,重点检查三件事:框是否包住了目标、有没有明显偏移、类别标签是否正确。如果发现框偏了,大概率是归一化基准搞错了(比如用了resize后的尺寸而不是原始尺寸)。如果发现某些目标没框,检查是不是被difficult过滤掉了,或者类别名不在映射表里。
注意:可视化时用的图像必须是和标注对应的原始图像。如果你用的是切图后的DOTA,确保图像和标签文件名一一对应。DOTA切图后的命名规则通常是
P0001__1024__0___0.png这种,标签文件同名但扩展名是.txt。
3. 训练配置与数据加载的实战细节
转换完格式只是第一步,真正训练时还有一堆坑等着。YOLO的数据加载器对标签文件的位置、命名、内容都有严格要求,稍有不慎就是“找不到标签”或者“标签格式错误”。这一章我按训练流程的顺序,把每个环节的关键配置和常见问题讲清楚。
3.1 数据集目录结构与YAML配置
YOLO训练时需要一个YAML文件来描述数据集路径和类别信息。标准结构如下:
# dataset.yaml path: /data/dota_yolo # 数据集根目录 train: images/train # 训练图像相对路径 val: images/val # 验证图像相对路径 nc: 15 # 类别数 names: # 类别名称列表,顺序必须和转换时的class_map一致 0: plane 1: ship 2: storage-tank 3: baseball-diamond 4: tennis-court 5: basketball-court 6: ground-track-field 7: harbor 8: bridge 9: large-vehicle 10: small-vehicle 11: helicopter 12: roundabout 13: soccer-ball-field 14: swimming-pool目录结构应该是这样的:
dota_yolo/ ├── dataset.yaml ├── images/ │ ├── train/ │ │ ├── P0001__1024__0___0.png │ │ └── ... │ └── val/ │ └── ... └── labels/ ├── train/ │ ├── P0001__1024__0___0.txt │ └── ... └── val/ └── ...关键点:图像和标签的文件名必须完全一致(除了扩展名)。YOLO的数据加载器会自动把images/train/xxx.png映射到labels/train/xxx.txt。如果你把标签放在别的目录,需要在YAML里额外配置labels路径,但我不建议这么做,容易出错。
另一个容易踩的坑是类别顺序。YAML里的names顺序必须和转换脚本里的DOTA_CLASSES完全一致。我见过有人转换时用的是字母序,YAML里写的是DOTA官方顺序,结果训练出来的模型把所有类别都搞混了。建议把类别列表写成一个单独的Python文件或者JSON,转换脚本和YAML生成脚本都从同一个源读取。
3.2 训练参数设置与显存优化
DOTA数据集切图后通常有1万到2万张1024x1024的图像,目标数量在20万到30万之间。这个规模用单卡训练是可行的,但需要合理设置batch size和输入尺寸。以下是我在RTX 3090(24GB显存)上的实测配置:
| 参数 | 值 | 说明 |
|---|---|---|
| imgsz | 640 | 输入尺寸,1024太大,640是精度和速度的平衡点 |
| batch | 16 | 3090上跑640尺寸可以到16,再大就OOM |
| epochs | 100 | DOTA数据量大,100轮基本收敛 |
| optimizer | SGD | YOLO官方推荐,momentum=0.937 |
| lr0 | 0.01 | 初始学习率 |
| lrf | 0.01 | 最终学习率系数 |
| warmup_epochs | 3 | 预热轮次 |
| mosaic | 1.0 | 马赛克增强,对小目标检测很关键 |
| mixup | 0.1 | 混合增强,不要设太高 |
| copy_paste | 0.1 | 复制粘贴增强,对遥感小目标有效 |
如果你显存不够,优先降batch而不是imgsz。因为遥感图像里小目标很多,输入尺寸降到512以下会严重损失小目标特征。我试过用416训练,小车辆(small-vehicle)的召回率直接掉了15个百分点。如果实在跑不动640,可以考虑用YOLOv8n或者YOLOv11n这种轻量模型,参数量小,显存占用低。
还有一个技巧是冻结骨干网络。如果你用的是预训练模型,前几轮可以冻结backbone,只训练检测头,这样显存占用能降低30%左右,而且收敛更快。等loss稳定后再解冻全部参数微调。YOLOv8的命令行参数是freeze=10,表示冻结前10层。
3.3 数据增强策略的取舍
遥感图像的数据增强和自然图像有很大不同。自然图像常用的随机裁剪、旋转、翻转在遥感里要谨慎使用。水平翻转和垂直翻转基本可以放心用,因为航拍视角下目标朝向本来就是任意的,翻转不会产生不合理的样本。90度旋转也可以用,但任意角度旋转要小心,因为旋转后图像边缘会出现黑边,而且标注框需要重新计算,容易引入误差。
Mosaic增强对遥感小目标非常有效。它把四张图拼成一张,相当于变相增加了小目标的出现频率。我实测下来,开启Mosaic后小车辆的AP能提升5到8个点。但Mosaic也有副作用:拼接处的目标可能被截断,导致标注框不完整。YOLOv8默认的Mosaic概率是1.0,我建议在训练后期(最后10到20轮)关掉Mosaic,让模型在真实分布上微调,这样能提升最终精度。
HSV增强要适度。遥感图像的颜色分布和自然图像不同,过度调整色调可能让模型学到无关特征。我一般把hsv_h设为0.015,hsv_s设为0.7,hsv_v设为0.4,比默认值保守一些。
提示:如果你用的是DOTA-v1.5或v2.0,类别数更多,小目标比例更高,建议把
imgsz提到768甚至896,同时把batch降到8。精度提升明显,但训练时间会翻倍。
4. 常见问题排查与避坑经验实录
这一章是我踩过的坑和帮别人排查过的问题的汇总。每个问题都附带了排查思路和解决方法,你可以当成速查表用。
4.1 标签格式错误与数据加载失败
问题现象:训练启动时报错Label format invalid或者No labels found。
排查思路:首先检查标签文件是否存在,文件名是否和图像一一对应。然后打开几个标签文件,确认每行是5个值(class_id + 4个归一化坐标),且坐标在0到1之间。常见错误包括:坐标没归一化(值大于1)、类别ID从1开始而不是0、每行有多余的空格或换行符。
解决方法:写一个校验脚本,遍历所有标签文件,统计每行的字段数和坐标范围。下面这个脚本我每次转换完都会跑一遍:
def validate_yolo_labels(label_dir, num_classes): """校验YOLO标签文件的合法性""" issues = [] for label_file in Path(label_dir).glob('*.txt'): with open(label_file, 'r') as f: for line_num, line in enumerate(f, 1): parts = line.strip().split() if len(parts) != 5: issues.append(f"{label_file}:{line_num} 字段数={len(parts)}") continue try: class_id = int(parts[0]) coords = list(map(float, parts[1:])) except ValueError: issues.append(f"{label_file}:{line_num} 解析失败") continue if class_id < 0 or class_id >= num_classes: issues.append(f"{label_file}:{line_num} 类别ID越界: {class_id}") for c in coords: if c < 0 or c > 1: issues.append(f"{label_file}:{line_num} 坐标越界: {c}") if issues: print(f"发现 {len(issues)} 个问题:") for issue in issues[:20]: print(f" {issue}") else: print("所有标签文件校验通过") return issues避坑经验:DOTA原始标注里有些目标的四个点可能不是按顺序排列的,甚至可能自相交。虽然min/max方法不受点顺序影响,但如果你用其他方法(比如计算多边形面积),点顺序就很重要了。建议在转换前先检查一下标注文件,看看有没有异常行。
4.2 训练loss不收敛或震荡
问题现象:训练开始后box_loss和cls_loss一直很高,或者剧烈震荡,mAP不上升。
排查思路:先确认标签是否正确(用上面的校验脚本)。然后检查学习率是否太大,DOTA数据集用0.01的初始学习率通常没问题,但如果你用的是小batch(比如4或8),学习率要相应降低。另外检查数据增强是否过猛,特别是Mosaic和Mixup同时开启时,早期训练可能不稳定。
解决方法:我一般会先用一个很小的子集(比如100张图)跑10轮,确认模型能过拟合。如果连100张图都学不会,那肯定是数据或配置有问题。过拟合测试通过后再上全量数据。另外,YOLOv8默认的close_mosaic=10表示最后10轮关闭Mosaic,这个设置对稳定收敛很有帮助,建议保留。
避坑经验:遥感图像里背景占比很高,有些图可能一个目标都没有。YOLO对空标签的处理是当作负样本,但如果负样本比例太高,模型会偏向于预测背景。我建议统计一下每张图的目标数量,如果平均少于1个,考虑过滤掉一些空图,或者用focal loss来平衡正负样本。
4.3 小目标漏检严重
问题现象:训练完成后,大目标(如飞机、储油罐)检测效果很好,但小目标(如车辆)召回率很低。
排查思路:小目标漏检通常有三个原因:输入尺寸太小、anchor尺寸不匹配、正样本分配策略不合适。YOLOv8用的是无anchor的检测头,所以anchor问题不存在,但输入尺寸和正样本分配仍然是关键。
解决方法:第一,提高输入尺寸到768或896,小目标的像素面积会增大,特征更明显。第二,调整正样本分配阈值,YOLOv8的box参数控制正样本的IoU阈值,默认是7.5,可以降到5.0让更多小目标被分配为正样本。第三,在数据增强里增加小目标的复制粘贴概率,YOLOv8的copy_paste参数就是干这个的,设到0.3左右效果明显。
避坑经验:DOTA里的小车辆(small-vehicle)在1024x1024的图上可能只有10到20个像素宽,转成640输入后只剩6到12个像素。这个尺寸已经接近YOLO下采样32倍后的极限了。如果业务场景对小目标要求很高,建议用YOLOv8x或者YOLOv11x这种大模型,或者用切片推理(SAHI)的方式在推理时放大图像。
4.4 类别不平衡与长尾分布
问题现象:某些类别(如直升机、游泳池)的AP远低于其他类别。
排查思路:统计每个类别的实例数量,DOTA数据集本身就有长尾问题。直升机可能只有几百个实例,而飞机有几千个。这种不平衡会导致模型偏向于预测高频类别。
解决方法:第一,在损失函数里给稀有类别更高的权重,YOLOv8支持通过cls参数调整分类损失的权重,但更精细的类别权重需要改源码。第二,对稀有类别做过采样,在数据加载时提高包含稀有类别的图像被选中的概率。第三,用focal loss替代默认的BCE loss,focal loss对难样本和稀有类别更友好。
避坑经验:我试过用类别权重的方式,但效果不如直接过采样。因为YOLO的损失是多个样本平均的,单个样本的权重调整会被平均掉。过采样更直接,但要注意不要过拟合稀有类别。一般把稀有类别的采样倍率控制在2到3倍就够了。
4.5 推理时框重叠与NMS调参
问题现象:推理结果里同一个目标出现多个重叠框,或者相邻目标被合并成一个框。
排查思路:这是NMS(非极大值抑制)参数的问题。YOLO默认的conf阈值是0.25,iou阈值是0.45。如果目标密集(比如港口里停了很多船),0.45的IoU阈值会导致相邻目标被误删。
解决方法:对于密集场景,把iou阈值提高到0.6甚至0.7,让NMS更宽松。同时适当提高conf阈值到0.4,过滤掉低置信度的误检。如果目标特别密集,可以考虑用Soft-NMS或者DIoU-NMS,但这些需要改推理代码。
避坑经验:遥感图像里有些目标天然就靠得很近,比如停车场里的车。这种情况下,水平框NMS很难做到完美。如果业务允许,可以考虑用旋转框检测(YOLOv8-OBB)来替代,旋转框的IoU计算更准确,NMS效果更好。YOLOv8-OBB是官方支持的,训练流程和普通YOLOv8几乎一样,只是标签格式变成了class x_center y_center width height angle。
5. 从转换到部署的完整链路复盘
把上面这些环节串起来,一个完整的DOTA转YOLO训练流程大概是这样的:下载DOTA数据集,切图(如果还没切),运行转换脚本生成YOLO标签,校验标签,配置YAML,启动训练,监控loss和mAP,训练完成后导出ONNX,用TensorRT或OpenVINO加速推理。整个链路我跑过不下十次,每次都会遇到一些新问题,但核心逻辑是不变的。
有一个容易被忽视的环节是验证集的选择。DOTA官方提供了train/val的划分,但如果你自己切图,划分可能不一致。我建议按照图像级别划分,而不是随机划分目标。因为同一张大图切出来的小图之间有重叠区域,如果随机划分,训练集和验证集可能包含同一区域的不同切片,导致验证指标虚高。正确的做法是按原始大图的ID划分,确保验证集的大图在训练集中没出现过。
另一个经验是保存转换脚本和配置。我习惯把转换脚本、类别映射、YAML配置、训练命令都写在一个README.md里,和数据集放在一起。过几个月再回来复现时,不用重新回忆当时是怎么转的。这个习惯帮我省了很多时间,特别是在团队协作时,别人接手你的项目能快速上手。
最后说一个部署时的坑:YOLO导出的ONNX模型输出的是归一化坐标,但不同版本的YOLO输出格式不一样。YOLOv5的输出是[batch, num_anchors, 5+nc],YOLOv8是[batch, 4+nc, num_anchors],YOLOv11又变了。如果你用ONNX Runtime或者TensorRT部署,一定要确认输出张量的形状和含义。我一般会写一个简单的Python脚本,用ONNX Runtime跑一张测试图,打印输出形状,确认无误后再写C++部署代码。
提示:如果你用的是AMD显卡,YOLO的官方训练脚本默认走CUDA,需要改成ROCm或者DirectML。ROCm版本的PyTorch安装比较麻烦,建议用Docker镜像。DirectML在Windows上可以用,但性能不如CUDA。如果只是推理,ONNX Runtime支持DirectML后端,速度还可以。
这个内容后续还可以这样扩展:如果你需要做旋转框检测,可以把转换脚本改成生成YOLO-OBB格式,只需要在最后加一个角度计算。角度可以从四个点的坐标用arctan2算出来,但要注意角度的周期性和边界情况。YOLO-OBB的角度范围是[-90, 0),和OpenCV的minAreaRect一致,可以直接用。