旋转目标检测数据格式转换:roLabelImg的XML转YOLO OBB txt实战 写这篇东西的起因很简单我手头有一批用 roLabelImg 标好的旋转目标检测数据标注文件全是 XML。项目中途决定换到 YOLO 系列做训练结果发现 XML 不能直接用必须转成 txt。光看这个需求觉得不就是改个后缀吗真动手才知道XML 里存的是“中心点坐标 宽高 角度”YOLO 要的是“归一化坐标 边界表达”两种体系差得不是一点半点。我花了一个下午把转换脚本写完整中间踩了不少坑。这篇文章就按我实际操作的顺序把格式差异、转换公式、完整脚本、验证方法、常见问题全部整理出来给同样被 roLabelImg 数据折磨的人一个直接能抄的答案。1. 为什么不能直接把 XML 改成 txt两种标注格式的底层差异1.1 roLabelImg 的 XML 到底存了什么robndbox 与 bndboxroLabelImg 是跟着 DOTA 这类旋转目标检测任务流行起来的标注工具。它标出来的 XML 结构本身继承自 Pascal VOC但多了旋转框专用的节点。一个普通目标的 XML 大概是这样的object typerobndbox/type nameship/name poseUnspecified/pose truncated0/truncated difficult0/difficult robndbox cx880.31/cx cy204.87/cy w106.40/w h72.83/h angle1.201/angle /robndbox /object关键就在robndbox节点里。它保存的并不是四个角点而是一组等价参数中心点cx、cy矩形自身宽度w、高度h以及旋转角度angle。这和高德地图、自动驾驶里常见的“中心点 长宽 航向角”是一个思路存储紧凑但模型侧并不直接吃这种表达。需要注意有些版本或导出配置里目标也可能是bndbox普通水平框里面是xmin, ymin, xmax, ymax。这通常意味着该目标没被旋转或者标注时用的是水平框模式。转换脚本必须兼容这两种节点否则遇到水平框直接跳过或者报错数据就缺了。除了每个objectXML 头部还有size节点里面带了图片的width、height、depth。这个信息非常关键后面做归一化全靠它不要自己去猜图片尺寸也不建议临时用 PIL 去读图。因为图片尺寸是标注时对齐的尺寸如果原始图片被预处理过、压缩过用当前图片尺寸去归一化坐标比例很容易错位。1.2 YOLO 要的 txt 是哪一种水平框、OBB 多边形、OBB 角度格式YOLO 官方早期版本只支持水平框txt 每行格式是class_id x_center y_center width height其中所有坐标都除以图片宽高做了归一化。如果你的目标全是水平框那转换很简单把bndbox或者robndbox的包围盒算出来再输出上面格式就行。但 roLabelImg 大量场景是旋转目标比如遥感图像里的船、飞机、车辆。你如果强行把旋转框的外接水平矩形送进普通 YOLO框里会混进大量背景模型训练出来精度很难看。所以更合理的目标是旋转框检测也就是 OBBOriented Bounding Box定向边界框。YOLOv5 之后的部分社区版本、YOLOv8 以及 YOLO11 都支持 OBB 任务。它们的标签格式常见有两种表达四点多边形格式每行是class_id x1 y1 x2 y2 x3 y3 x4 y4全部归一化。这种格式通用性最强很多工具、框架都能解析也是我推荐优先转换的目标。角度格式每行是class_id cx cy w h angle同样归一化中心点、宽高角度有的用弧度、有的用度不同框架定义不一样。这种格式需要额外确认框架要求的角度范围和旋转方向踩坑概率更大。既然是“roLabelImg 标定数据转 YOLO 可用 txt”最稳妥的方案就是转成四点多边形格式。四点表达不依赖角度定义即使个别框架角度约定不同也可以通过四点直接恢复旋转框兼容性最好。2. 转换前的几个关键选择坐标系、角度单位和类别映射2.1 角度到底是弧度还是角度先搞清楚再写公式这是整个转换里最容易翻车的地方。roLabelImg 不同版本、不同配置下XML 里的angle存在弧度制或者角度制两种可能。常见默认情况是弧度制所以你会看到angle数值一般是-3.14到3.14之间或者0到3.14之间。但有些改动过的版本存的是角度制常见数值会是类似90.0、180.0的样子。写脚本时我建议做一个自动判断相对来说更省心。我的判断逻辑很简单如果angle的绝对值大于2 * math.pi就认为它是角度制转换时用math.radians()转成弧度否则保持原值。这套逻辑不一定覆盖所有角落情况但实际跑过的数据基本都能正确识别。如果不放心就在转换后用可视化脚本抽查几张原理上不会错。角度方向同样要留意。图像坐标系里 y 轴向下旋转方向的表现和数学坐标系里正好有点反直觉。公式层面我们用标准三角函数算出来的四个点在图片上是否看着和 roLabelImg 里一致全靠可视化验证。你不需要记太多结论只要记住一点比例、大小对了但方向错了多半是宽高方向选错或者角度符号取反。2.2 归一化到底除以谁用 XML 里的 size 而不是猜YOLO 的 txt 里所有坐标都必须落在[0, 1]这个区间内。也就是说x、y要分别除以图片宽度、高度w、h同理。这里有个坑很多人直接用cv2.imread()读取图片后拿到的宽高去做归一化。如果你的图片是原始标注图那没问题但如果图片曾经被旋转补边、裁剪缩放、甚至只是改了分辨率再用当前图片尺寸就会导致所有框全部漂移。正确做法是从 XML 的size节点读width和height。roLabelImg 在保存标注时会把图片尺寸写进 XML这个尺寸本身就是标注时依赖的坐标系统。如果你的数据集有数据清洗流程也要保证清洗后的图片尺寸和 XML 里记录的一致否则必须先对齐图片再转标签。2.3 类别 ID 的顺序classes.txt 是唯一的映射基准XML 里name存的是类别名称比如ship、airplane。YOLO 的 txt 第一字段是类别整数 ID不是名称。所以必须有一个类别顺序表让同一个名称永远对应同一个 ID。这个顺序表就是训练时data.yaml里的names列表或者单独维护的classes.txt。我的做法是在转换脚本顶部写死一个CLASS_NAMES列表顺序必须和data.yaml完全一致。如果一个 XML 里出现列表之外的类别名脚本直接抛异常而不是悄悄忽略这样能第一时间发现标注名拼写不一致、多了空格、大小写不统一等问题。类别映射的排查经验是如果训练时发现某些目标类别错乱比如船变成了飞机十有八九是转换脚本里的CLASS_NAMES顺序和data.yaml里不一致。改之前先去看data.yaml的 names 到底是按什么顺序排的不要凭记忆猜。3. 核心公式从一个旋转矩形到四个角点3.1 旋转矩形原理和公式推导从 roLabelImg 的robndbox拿到的是中心点(cx, cy)、宽w、高h、角度angle。要转成四点格式本质上是把矩形数学模型展开。旋转矩形可以理解成先在水平状态下以原点为中心建立一个矩形宽边沿 x 轴、高边沿 y 轴然后绕中心旋转角度angle最后平移到(cx, cy)。根据这个思路宽边方向的单位向量是(cos(angle), sin(angle))高边方向的单位向量是(-sin(angle), cos(angle))于是宽边偏移向量是(w/2 * cos(angle), w/2 * sin(angle))高边偏移向量是(-h/2 * sin(angle), h/2 * cos(angle))。四个角点可以这样算p1 (cx - 宽偏移 高偏移)不对需要统一顺时针顺序。更清楚一点设宽边偏移为v_w高边偏移为v_h那么从中心出发p1 中心 - v_w - v_h p2 中心 v_w - v_h p3 中心 v_w v_h p4 中心 - v_w v_h这样得到的四个点顺序是围绕中心顺时针排列的。只要角点顺序稳定YOLO OBB 训练时就能正确还原矩形。公式里的angle是弧度制。如果你处理的是角度制先转换再代入。图像坐标系 y 向下并不影响这个公式的代数推导只是画出来时视觉上顺逆时针会发生变化实际验证一下就好。3.2 手工验算一遍把数字代入看结果为了确认公式没写错我习惯先手工算一个简单例子。假设图片宽度 1000高度 800。某个目标cx 300 cy 200 w 100 h 50 angle 0当角度为 0 时宽边水平方向高边垂直方向所以两个偏移向量分别是v_w (50, 0) v_h (0, 25)四个角点就变成p1 (300 - 50 - 0, 200 - 0 - 25) (250, 175) p2 (300 50 - 0, 200 0 - 25) (350, 175) p3 (300 50 0, 200 0 25) (350, 225) p4 (300 - 50 0, 200 - 0 25) (250, 225)这不就是一个水平矩形嘛和实际完全吻合。再把角度改成math.pi / 2也就是 90 度此时cos0, sin1v_w (0, 50) v_h (-25, 0)角点就变成p1 (300 50, 200 25)? 实际算 p1 (300 - 0 - (-25), 200 - 50 - 0) (325, 150) p2 (300 0 - (-25), 200 50 - 0) (325, 250) ...四个点整体变成竖直的长矩形和 90 度旋转后的感觉一致。手工验算能让我清楚地确认公式的方向逻辑也方便排查脚本里的符号错误。4. 完整转换脚本roLabelImg XML 转 YOLO OBB txt4.1 完整 Python 脚本可跑下面这个脚本是我实际在用的一个版本支持robndbox和bndbox两种节点自动判断弧度或角度输出 YOLO OBB 四点格式。你可以直接复制改一下路径和类别列表就能跑。import os import math import glob import xml.etree.ElementTree as ET # 类别顺序必须和训练时 data.yaml 里的 names 保持一致 CLASS_NAMES [airplane, ship, vehicle] def parse_xml(xml_path): 解析 roLabelImg 生成的 XML返回图片宽高和所有目标框。 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) boxes [] for obj in root.findall(object): name obj.find(name).text.strip() difficult 0 ele_diff obj.find(difficult) if ele_diff is not None and ele_diff.text: difficult int(ele_diff.text) obj_type robndbox ele_type obj.find(type) if ele_type is not None and ele_type.text: obj_type ele_type.text.strip() if obj_type robndbox: rb obj.find(robndbox) cx float(rb.find(cx).text) cy float(rb.find(cy).text) w float(rb.find(w).text) h float(rb.find(h).text) angle float(rb.find(angle).text) # 自动判断如果绝对值很大大概率是角度制转成弧度 if abs(angle) 2 * math.pi: angle math.radians(angle) boxes.append((name, cx, cy, w, h, angle)) else: # 普通水平框 bndbox bb obj.find(bndbox) xmin float(bb.find(xmin).text) ymin float(bb.find(ymin).text) xmax float(bb.find(xmax).text) ymax float(bb.find(ymax).text) cx (xmin xmax) / 2.0 cy (ymin ymax) / 2.0 w xmax - xmin h ymax - ymin boxes.append((name, cx, cy, w, h, 0.0)) return img_w, img_h, boxes def get_corners(cx, cy, w, h, angle): 根据中心点、宽高、弧度角求矩形四个角点。 cos_a math.cos(angle) sin_a math.sin(angle) vw_x w / 2.0 * cos_a vw_y w / 2.0 * sin_a vh_x -h / 2.0 * sin_a vh_y h / 2.0 * cos_a p1 (cx - vw_x - vh_x, cy - vw_y - vh_y) p2 (cx vw_x - vh_x, cy vw_y - vh_y) p3 (cx vw_x vh_x, cy vw_y vh_y) p4 (cx - vw_x vh_x, cy - vw_y vh_y) return [p1, p2, p3, p4] def convert_xml_to_txt(xml_path, out_dir, class_names): img_w, img_h, boxes parse_xml(xml_path) img_name os.path.splitext(os.path.basename(xml_path))[0] lines [] for name, cx, cy, w, h, angle in boxes: if name not in class_names: raise ValueError(f{xml_path} 里发现未定义类别{name}) cls_id class_names.index(name) corners get_corners(cx, cy, w, h, angle) norm_pts [] for px, py in corners: norm_pts.append(px / img_w) norm_pts.append(py / img_h) line_parts [str(cls_id)] [f{v:.6f} for v in norm_pts] lines.append( .join(line_parts)) os.makedirs(out_dir, exist_okTrue) out_path os.path.join(out_dir, img_name .txt) with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines)) return out_path # 批量转换 if __name__ __main__: xml_dir your_xml_dir # 改成你的 XML 目录 txt_dir your_txt_dir # 改成你的输出目录 for xml_path in glob.glob(os.path.join(xml_dir, *.xml)): out convert_xml_to_txt(xml_path, txt_dir, CLASS_NAMES) print(已生成:, out)这段脚本的逻辑很直接先解析 XML 拿到图片尺寸和目标框再根据角点公式算出四个点最后归一化写入 txt。如果遇到未定义类别直接抛异常这是有意为之好让我们在数据准备阶段就发现问题。4.2 效果验证用 OpenCV 画框看转换是否成功脚本能跑通不等于转换正确。坐标运算里一旦角度方向或者宽高对应关系写错程序不会报错但转出来的框和原图根本对不上。所以转换完一定要可视化抽查。我写了一个快速验证脚本读取 txt 中的归一化四点乘回图片宽高后画在原图上import cv2 import numpy as np def draw_txt_on_image(image_path, txt_path): img cv2.imread(image_path) h, w img.shape[:2] with open(txt_path, r, encodingutf-8) as f: for line in f: parts line.strip().split() cls_id int(parts[0]) coords [float(x) for x in parts[1:]] pts np.array(coords, dtypenp.float64).reshape(-1, 2) pts[:, 0] * w pts[:, 1] * h pts np.int0(pts) cv2.drawContours(img, [pts], -1, (0, 0, 255), 2) cv2.putText(img, CLASS_NAMES[cls_id], tuple(pts[0]), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 2) return img # 示例 # img draw_txt_on_image(your_image.jpg, your_txt_dir/your_image.txt) # cv2.imwrite(check.jpg, img)验证时重点看三件事框是不是刚好包住目标有没有明显偏移。框的朝向和目标是否一致如果整体转了 90 度说明宽高长边方向理解反了。框有没有超出图片区域如果轻微越界是标注本身的问题如果大面积越界多半是归一化除错了尺寸。我用这个方法抽查过几百张图确认没问题之后才拿去训练后面模型收敛也正常。5. 不同框架下的格式变形普通 YOLO、OBB 角度格式、分割格式5.1 转普通水平检测框如果你的任务并不需要旋转框只想用普通 YOLO 训练水平目标检测那只要把旋转框的外接水平矩形算出来就行。可以用cv2.minAreaRect的四个角点取min和max也可以直接从get_corners得到的四个点里求包围盒。输出格式为class_id x_center_norm y_center_norm width_norm height_norm其中x_center_norm (xmin xmax) / 2 / img_w同理y_center_normwidth_norm (xmax - xmin) / img_w。这里有一个取舍外接水平矩形如果目标本身旋转角度大框里会包含大量背景容易被别的目标干扰。所以即便只是做水平检测也建议先看看数据里旋转比例再决定是否真要放弃角度信息。5.2 转带角度的 OBB 标签cx cy w h angle如果你用的框架明确要求 OBB 角度格式比如某些基于 YOLOv5 改来的旋转框分支那么脚本可以改成直接输出class_id cx_norm cy_norm w_norm h_norm angle其中cx_norm cx / img_wcy_norm cy / img_hw_norm w / img_wh_norm h / img_h角度保留弧度值或者按框架要求转成角度制。使用角度格式时最麻烦的是角度范围约定。有些框架定义角度为长边与 x 轴的夹角范围在[-90, 90)度有些框架定义成[0, 180)还有框架要求角度必须是负数。我建议先去看框架源码里解码标签的部分确认它期望的角度定义再在脚本里做一步适配。没有统一标准前直接套用很容易练着练着就 Loss 爆掉。相比之下四点格式绕开了角度原始定义所以我在默认方案里都优先转四点格式。5.3 类别数量不一致 / 多级目录处理实际项目往往不是干净的一个目录。XML 散在多个子文件夹里图片和标注可能还分训练、验证、测试三个目录。批量脚本最好支持遍历子目录from pathlib import Path xml_root Path(your_xml_root) for xml_path in xml_root.rglob(*.xml): rel xml_path.relative_to(xml_root) out_dir Path(your_txt_root) / rel.parent convert_xml_to_txt(str(xml_path), str(out_dir), CLASS_NAMES)rglob会递归找到所有 XML同时保持目录结构输出 txt 时也放到对应子目录里后面做训练集验证集划分比较方便。类别数量不一样也很常见比如新数据加了一个新类别转换成 txt 之前先把CLASS_NAMES更新并同步修改data.yaml。否则脚本抛异常或者类别 ID 错位训练出来就是一团浆糊。6. 常见问题与排查技巧实录6.1 问题速查表现象可能原因处理方法转换出来的 txt 是空文件XML 里没有object或者解析时被跳过检查 XML 内容和脚本里的obj_type判断类别 ID 全部是 0脚本里的类别列表顺序和实际数据不对打印name和cls_id核对框整体偏移归一化时用了错误的图片尺寸用 XML 里size的宽高别用图片当前尺寸框朝向相反角度符号取反或宽高向量方向不对可视化后手动调整get_corners里的 / -符号框变成旋转 90 度长边对应的宽度计算反了把w和h互换再转一次转换抛ValueError数据里有未定义类别名把新类别加进CLASS_NAMEStxt 坐标出现负数或大于 1原标注点落在图片边缘外视情况做clip(0, 1)但不能解决大范围越界中文类别名乱码XML 编码或终端编码问题打开 XML 看头部 encoding脚本用 utf-8 读写这张表是我自己踩坑后整理出来的。里面最坑的一条就是“类别 ID 全部是 0”我第一次遇到时还以为是脚本逻辑写错了最后发现是新旧两个项目的类别顺序不一致导致所有框都被映射成第一个类别。6.2 排查技巧与我的经验先说一个大原则能不写复杂逻辑就不写尽量保持转换脚本朴素可读。格式转换本质是一个确定性过程越花哨越容易出错调试成本也越高。具体排查时我会先拿单张图打通全流程再批量处理。单张图跑的流程是XML 转 txttxt 可视化画框和 roLabelImg 里原标注对比。只要单张图验证没问题批量脚本一般不会蹦出格式问题。还有一个实用小技巧转换完成后随机挑 20 个 txt 文件检查行数再和对应 XML 里的object数量对比。如果某个文件少了目标那多半是脚本把某种节点类型漏掉了。用下面这句可以快速看for f in your_txt_dir/*.txt; do echo $f $(wc -l $f); done另外不要忽略 XML 里的difficult字段。有些数据把难例标成difficult1。如果训练时不想包含这些样本转换脚本里可以增加条件过滤。但过滤前先确认你的任务定义很多比赛数据集里难例也是训练标签的一部分过滤错了反而降低数据量。最后再分享一个我后来一直在用的习惯转换脚本不要用完了就删把它放在数据集目录下并且写上固定版本。因为数据集会迭代新标注数据进来可能生成新的 XML 格式只要有脚本重跑一次就完事还能横向对比不同批次的数据是否都转对了。如果没有脚本就只能手动处理一旦数据量大那就真的会让人崩溃。