资讯动态

DOTA旋转框转YOLO格式全解析:从坐标转换到训练验证

发布时间:2026/9/19 14:03:12 来源:尧图企业网站定制
遥感图像里的目标检测和自然图像完全是两码事。自然图像里一辆车横平竖直标注一个水平矩形框就够了但遥感图像是从天上往下拍的飞机、舰船、油罐这些目标朝向任意一个水平框里塞满了背景像素模型学起来非常吃力。DOTA数据集正是为了解决这个问题而生的它用旋转框Oriented Bounding BoxOBB来标注目标框能贴着目标的长轴走。可问题来了YOLO系列原生只认水平框拿到DOTA的标注文件直接喂进去根本跑不通。这篇内容就把DOTA旋转框转YOLO格式这件事从头到尾拆开讲清楚包括坐标系的坑、角度归一化的细节、代码实现以及转换完之后怎么验证。不管你是刚接触遥感检测的新手还是已经跑过几个模型但被坐标转换卡住的老手应该都能从里面找到有用的东西。1. DOTA标注格式到底长什么样1.1 DOTA原始标注的字段结构DOTA数据集的标注文件是纯文本每张图对应一个txt文件文件名和图片名一致。每一行代表一个目标实例字段用空格分隔。以DOTA v1.0为例一行的格式是这样的x1 y1 x2 y2 x3 y3 x4 y4 category difficult前八个数字是旋转框四个角点的坐标顺序是左上、右上、右下、左下但这个“左上”是相对于目标自身朝向而言的不是图像坐标系里的左上。category是类别名比如plane、ship、storage-tank等。difficult是0或1表示这个目标是否难以识别训练时通常可以选择忽略difficult1的样本。这里有个很容易搞混的点DOTA的四个角点顺序是固定的但不同版本之间可能有细微差异。DOTA v1.0和v1.5的格式基本一致v2.0增加了一些类别格式没变。你在处理之前最好先确认自己用的是哪个版本类别列表不一样后续做类别映射的时候会出问题。1.2 旋转框和水平框的本质差异水平框用四个值表示中心x、中心y、宽、高。旋转框多了一个角度参数表示方式有好几种。DOTA用的是四角点表示法而很多算法框架比如mmrotate内部用的是cx, cy, w, h, angle表示法。这两种表示之间的转换不是简单的数学公式套用涉及到角度定义方向、角度范围、长宽定义等一堆约定问题。我打个比方你就明白了。水平框就像你用手机拍一张照片然后画一个正正方方的框把目标圈起来。旋转框则是你拿一个可以旋转的相框调整角度让框刚好贴合目标的轮廓。显然旋转框更紧凑框内的背景更少但代价是标注和转换都更复杂。对于YOLO来说它需要的是归一化后的水平框坐标格式是class_id cx_norm cy_norm w_norm h_norm其中cx_norm、cy_norm、w_norm、h_norm都是相对于图像宽高的归一化值范围在0到1之间。所以从DOTA到YOLO的转换核心工作就是把四角点旋转框转成水平框再做归一化。1.3 为什么不能直接把旋转框当水平框用有人可能会想DOTA给了四个角点我直接取x坐标的最小最大值和y坐标的最小最大值不就得到水平框了吗这个思路方向是对的但直接这么做会有一个严重问题框会变得很大。举个例子一架飞机以45度角停放旋转框紧贴机身面积可能只有水平外接框的一半甚至更少。如果你直接用外接水平框框内会包含大量背景模型在训练时会被这些背景干扰导致精度下降。更麻烦的是当多个目标密集排列时外接水平框之间会大量重叠NMS非极大值抑制阶段会误杀很多正确检测。所以正确的做法不是简单取外接框而是要根据具体需求决定策略。如果你的YOLO版本支持旋转框比如YOLOv8-OBB那你可以直接转成YOLO-OBB格式保留角度信息。如果用的是标准YOLO那就只能退而求其次用水平框但要在数据增强和后处理上做补偿。2. 坐标转换的数学原理与常见误区2.1 从四角点到中心点加宽高加角度要把DOTA的四角点转成cx, cy, w, h, angle步骤是这样的。先算中心点cx (x1 x2 x3 x4) / 4 cy (y1 y2 y3 y4) / 4然后算宽和高。这里要注意宽不一定是水平方向的跨度而是沿目标长轴方向的长度。对于DOTA的角点顺序左上、右上、右下、左下宽是第一条边从角点1到角点2的长度高是第二条边从角点2到角点3的长度w sqrt((x2 - x1)^2 (y2 - y1)^2) h sqrt((x3 - x2)^2 (y3 - y2)^2)角度则是第一条边与x轴正方向的夹角angle atan2(y2 - y1, x2 - x1)这个角度是弧度制范围在负pi到pi之间。不同的框架对角度范围有不同的要求比如mmrotate通常要求角度在[-pi/2, pi/2)或者[-pi/4, 3pi/4)之间具体取决于版本和配置。你在转换的时候一定要查清楚目标框架的要求不然角度对不上框会转到完全错误的方向。2.2 角度归一化的坑角度归一化是旋转框转换里最容易出错的地方。举个例子一个框的角度是pi/2也就是90度另一个框的角度是-pi/2也就是-90度在几何上这两个框的方向是一样的因为矩形旋转180度后和原来重合。但如果不做归一化算法会认为它们是两个不同的角度导致后续计算出现混乱。常见的归一化策略是把角度限制在[-pi/4, 3pi/4)或者[-pi/2, pi/2)范围内。具体做法是如果角度超出范围就加减pi的整数倍把它拉回来。同时要注意当角度变化时宽和高的定义可能会互换。比如一个框原本w100, h50, angle0旋转90度后变成w50, h100, anglepi/2但几何上还是同一个框。所以在归一化的同时可能需要交换w和h。我在实际项目里踩过这个坑。当时用mmrotate训练DOTA转换脚本里没做角度归一化结果模型训练loss一直不收敛排查了两天才发现是角度范围不一致导致的。后来在转换脚本里加了一行归一化代码问题立刻解决。这个教训告诉我坐标转换的每一步都要验证不能想当然。2.3 坐标系原点和y轴方向的差异DOTA的坐标系原点在图像左上角x轴向右y轴向下。这是计算机视觉里最常见的图像坐标系。但有些算法框架或者可视化工具可能用不同的坐标系比如原点在左下角、y轴向上。如果你在转换过程中涉及到多个工具链一定要确认每个环节的坐标系定义。还有一个容易忽略的点是图像尺寸。DOTA的原始图像非常大通常是4000x4000甚至更大。很多训练框架会把大图裁剪成小图比如1024x1024再训练。裁剪之后目标的坐标要相应平移。如果你先转换格式再裁剪和先裁剪再转换结果是不一样的。我建议的做法是先在原始图像上做格式转换然后再裁剪这样坐标计算更直观不容易出错。3. 转换代码的完整实现与逐行解读3.1 环境准备与依赖转换脚本用Python写就行核心依赖只有numpy和opencv-python。如果你需要可视化验证可以加上matplotlib。不需要安装什么重型框架轻量级脚本跑起来很快。pip install numpy opencv-python matplotlib脚本的输入是DOTA的标注目录和图像目录输出是YOLO格式的标注文件和对应的图像列表。我一般会把训练集和验证集分开处理所以脚本里会加一个划分逻辑。3.2 核心转换函数先看最核心的转换函数把一行DOTA标注转成YOLO格式import numpy as np import os from pathlib import Path def dota_line_to_yolo(line, img_w, img_h, class_map): parts line.strip().split() if len(parts) 9: return None coords list(map(float, parts[:8])) category parts[8] difficult int(parts[9]) if len(parts) 9 else 0 if difficult 1: return None if category not in class_map: return None x1, y1, x2, y2, x3, y3, x4, y4 coords # 计算外接水平框 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) # 归一化 cx (x_min x_max) / 2.0 / img_w cy (y_min y_max) / 2.0 / img_h w (x_max - x_min) / img_w h (y_max - y_min) / img_h # 边界裁剪 cx np.clip(cx, 0, 1) cy np.clip(cy, 0, 1) w np.clip(w, 0, 1) h np.clip(h, 0, 1) class_id class_map[category] return f{class_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}这段代码看起来简单但有几个细节值得说。第一我跳过了difficult1的样本因为这些样本在训练时通常不参与loss计算保留反而增加处理复杂度。第二我做了边界裁剪因为DOTA标注里偶尔会有坐标超出图像范围的情况不裁剪的话归一化后可能小于0或大于1YOLO训练时会报错。第三归一化保留了6位小数精度足够了再多也是浪费存储空间。3.3 批量处理与数据集划分单个文件转换很简单但DOTA有几千张图必须批量处理。同时还要划分训练集和验证集def convert_dota_to_yolo(dota_img_dir, dota_label_dir, output_dir, class_map, val_ratio0.2): img_dir Path(dota_img_dir) label_dir Path(dota_label_dir) out_img_dir Path(output_dir) / images out_label_dir Path(output_dir) / labels out_img_dir.mkdir(parentsTrue, exist_okTrue) out_label_dir.mkdir(parentsTrue, exist_okTrue) img_files sorted(list(img_dir.glob(*.png)) list(img_dir.glob(*.jpg))) np.random.seed(42) indices np.random.permutation(len(img_files)) val_count int(len(img_files) * val_ratio) val_indices set(indices[:val_count]) train_list [] val_list [] for idx, img_path in enumerate(img_files): img cv2.imread(str(img_path)) if img is None: continue img_h, img_w img.shape[:2] label_path label_dir / (img_path.stem .txt) if not label_path.exists(): continue yolo_lines [] with open(label_path, r) as f: for line in f: result dota_line_to_yolo(line, img_w, img_h, class_map) if result: yolo_lines.append(result) if not yolo_lines: continue out_label_path out_label_dir / (img_path.stem .txt) with open(out_label_path, w) as f: f.write(\n.join(yolo_lines)) if idx in val_indices: val_list.append(str(img_path)) else: train_list.append(str(img_path)) with open(Path(output_dir) / train.txt, w) as f: f.write(\n.join(train_list)) with open(Path(output_dir) / val.txt, w) as f: f.write(\n.join(val_list)) print(f训练集: {len(train_list)} 张, 验证集: {len(val_list)} 张)这里用了一个固定随机种子42来划分数据集保证每次运行结果一致。这个习惯很重要不然你调参的时候数据集变了根本没法对比实验结果。另外我跳过了没有标注的图片因为YOLO训练时空白图片虽然可以用作背景负样本但DOTA里这种情况很少直接跳过更省事。3.4 类别映射表的构建DOTA v1.0有15个类别v2.0有18个。你需要根据自己用的版本建立映射表DOTA_V1_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 ] class_map {name: idx for idx, name in enumerate(DOTA_V1_CLASSES)}注意类别顺序要和你的YOLO配置文件里的names一致不然训练出来的模型类别全是乱的。我见过有人转换完忘了改yaml文件结果模型把飞机识别成船排查了半天才发现是类别索引对不上。4. 转换结果的验证方法与常见错误排查4.1 可视化验证把框画回图上转换完不验证等于没转换。最直接的验证方法就是把YOLO格式的框画回原图看看和DOTA原始标注是否一致def visualize_yolo_label(img_path, label_path, class_names): img cv2.imread(img_path) h, w img.shape[:2] with open(label_path, r) as f: for line in f: parts line.strip().split() class_id int(parts[0]) cx, cy, bw, bh map(float, parts[1:5]) x1 int((cx - bw / 2) * w) y1 int((cy - bh / 2) * h) x2 int((cx bw / 2) * w) y2 int((cy bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, class_names[class_id], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imwrite(vis_result.jpg, img)跑几张图看看如果框的位置明显偏移或者大小不对那肯定是转换出了问题。重点检查归一化用的图像尺寸是否正确以及类别映射有没有搞反。4.2 常见错误一坐标没有归一化这是最常见的错误。YOLO要求坐标是0到1之间的归一化值如果你直接把像素坐标写进去训练时loss会爆炸。判断方法很简单打开转换后的txt文件看看数值是不是都在0到1之间。如果有大于1的数那就是忘了除以图像宽高。4.3 常见错误二图像尺寸读取错误DOTA的图片格式有png和jpg两种有些图片可能读取失败返回None。如果你没做判断直接取shape程序会崩溃。更隐蔽的问题是有些图片的EXIF信息里包含了旋转角度opencv读取时可能不会自动应用旋转导致图像尺寸和实际显示的不一致。解决办法是用cv2.imread读取后检查shape必要时用PIL重新读取。4.4 常见错误三类别索引越界如果你的class_map里没有某个类别但标注文件里出现了代码会报KeyError。DOTA不同版本的类别列表不一样v1.5比v1.0多了container-cranev2.0又多了airport和helipad。处理之前先用脚本统计一下标注文件里出现了哪些类别确保class_map覆盖全了。4.5 常见错误四空标注文件有些图片可能没有任何目标对应的标注文件是空的。这种图片在YOLO训练时可以作为背景负样本但需要确保你的数据加载器支持空标注。我一般会在转换时统计一下空标注的比例如果比例很低就直接跳过如果比例较高就保留并在训练配置里做相应处理。5. 转换完成后的训练配置要点5.1 YOLO数据配置文件转换完成后需要写一个yaml文件告诉YOLO去哪里找数据path: /data/dota_yolo train: train.txt val: val.txt nc: 15 names: 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-poolnc是类别数names的顺序必须和class_map一致。这个文件里任何一处不一致训练都会出问题。5.2 图像尺寸与批大小的权衡DOTA原始图像很大直接训练不现实。通常的做法是裁剪成1024x1024或者800x800的patch。裁剪可以在转换前做也可以在转换后做。我倾向于转换后裁剪因为这样标注文件已经归一化了裁剪时只需要按比例调整坐标逻辑更清晰。批大小取决于你的显卡显存。1024x1024的输入8GB显存大概能跑batch size 4到8。如果显存不够可以减小输入尺寸或者用梯度累积。不要为了跑大batch而把图像缩得太小遥感目标本来就小缩太多就什么都看不清了。5.3 数据增强的注意事项遥感图像的数据增强和自然图像不太一样。翻转和旋转是常用的但要注意旋转会改变目标的角度分布。如果你的任务对角度敏感比如舰船方向检测旋转增强要谨慎使用。另外Mosaic增强在遥感场景下效果不一定好因为拼接后的图像会出现不自然的边界模型可能学到错误的上下文信息。我一般会先用默认增强跑一版baseline然后逐步调整增强策略每次只改一个参数观察验证集指标的变化。这样虽然慢但能清楚知道每个改动的影响。6. 从水平框到旋转框什么时候该升级6.1 水平框的局限性用水平框训练YOLO在DOTA上能跑出结果但精度上限有限。尤其是对于长宽比大的目标比如桥梁、舰船水平框包含太多背景模型很难学到目标的精确边界。另外在密集场景下水平框之间的重叠会导致NMS误杀召回率上不去。如果你的应用场景对精度要求不高比如只是粗略统计某类目标的数量水平框够用了。但如果要做精确的定位或者方向估计就必须上旋转框。6.2 YOLO-OBB格式简介YOLOv8之后的版本支持OBBOriented Bounding Box任务标注格式是class_id cx cy w h angle其中angle是弧度制范围在0到pi/2之间。这个格式比DOTA的四角点简洁但转换时需要注意角度定义的一致性。YOLO-OBB的角度定义和DOTA不完全一样DOTA的角度是第一条边与x轴的夹角YOLO-OBB的角度定义可能因版本而异转换前一定要查文档。6.3 转换到YOLO-OBB的额外步骤从DOTA转到YOLO-OBB核心是在水平框转换的基础上加上角度计算和归一化。角度不需要归一化到0到1但需要确保范围正确。另外YOLO-OBB的w和h定义可能和DOTA不同有时候需要交换。这些细节看起来琐碎但每一个都可能让你的模型训练失败。我的建议是先用少量数据做转换可视化验证无误后再批量处理。转换脚本写好后拿几张有代表性的图片包含不同类别、不同角度、不同密集程度跑一遍确认框的位置和角度都正确再全量转换。这个验证步骤花不了多少时间但能帮你避免后面几天的无效训练。7. 实际项目中的经验与避坑建议7.1 数据清洗比转换本身更重要DOTA数据集虽然质量不错但也不是完美的。我遇到过标注框严重偏离目标的情况也遇到过同一目标被标注了两次的问题。转换之前最好做一轮数据清洗把明显异常的标注剔除。判断异常的方法包括框的面积过大或过小、长宽比极端、坐标超出图像范围等。清洗的阈值需要根据具体类别来定。比如small-vehicle的框面积通常很小如果你用统一的面积阈值可能会把正常的小目标误删。我一般会按类别统计框的尺寸分布然后设定类别相关的阈值。7.2 转换脚本要可复现转换脚本一定要固定随机种子并且把所有的配置参数类别映射、验证集比例、图像尺寸等都写在脚本开头或者配置文件里。这样你后面想调整的时候改一个参数重新跑就行不用去翻代码找哪里需要改。我习惯把配置写成一个字典放在脚本最上面一目了然。7.3 验证集的选择要谨慎DOTA的图像来自不同的场景有些是城市有些是港口有些是农田。如果随机划分验证集可能会出现训练集和验证集分布不一致的情况。更好的做法是按场景划分确保验证集覆盖各种场景。如果做不到至少要多跑几次不同的随机划分看看指标是否稳定。7.4 训练前的最后检查清单在启动训练之前我一般会过一遍这个清单检查项检查方法常见问题标注文件格式打开txt看数值范围坐标未归一化类别映射对比yaml和class_map索引不一致图像路径检查train.txt里的路径路径错误或文件缺失图像尺寸随机抽几张图看shape尺寸不一致空标注统计空文件比例比例过高影响训练可视化画框回原图框位置偏移这个清单看起来简单但每次都能帮我发现一些小问题。花十分钟检查省几小时排查。7.5 关于mmrotate和YOLO的选择如果你主要做旋转框检测mmrotate是更专业的选择它原生支持DOTA格式不需要做复杂的格式转换。但mmrotate的环境配置比YOLO复杂训练速度也慢一些。YOLO的优势是生态成熟、部署方便、社区活跃。如果你的任务可以用水平框近似或者你愿意用YOLO-OBB那YOLO是更务实的选择。我个人的做法是先用YOLO水平框快速跑一个baseline确认数据和流程没问题然后再根据精度需求决定是否升级到旋转框方案。这样风险最低迭代速度也最快。7.6 部署时的坐标还原训练完之后部署模型时如果用的是水平框输出的坐标是归一化的需要乘以图像宽高还原成像素坐标。如果图像经过了裁剪或缩放还要做逆变换还原到原始图像坐标系。这一步很容易出错尤其是当预处理包含了多种变换时。我的建议是把预处理和后处理的代码写在一起确保变换和逆变换是严格对应的并且用单元测试验证。遥感图像处理这个领域数据格式转换看似是脏活累活但实际上是整个流程的基础。转换做不对后面训练再调参也是白搭。希望这篇内容能帮你把DOTA到YOLO的转换流程跑通少走一些弯路。如果你在转换过程中遇到了其他奇怪的问题欢迎一起交流。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价