做旋转目标检测的都知道,标注完一批数据只是万里长征第一步,最磨人的反而是格式转换。rolabelimg保存的是Pascal VOC风格的XML,里面还夹带一个robndbox旋转框节点;而YOLO系训练要的是归一化的TXT,如果跑OBB还得是四点坐标。这个“xml转txt”的需求我前前后后处理过好多次,今天把完整的踩坑过程和可直接抄的脚本整理出来,希望能帮正在做遥感、航拍、版面检测这类旋转目标的同学省下半天时间。这事适合谁呢?一句话:只要你的数据是用rolabelimg标的,而训练打算用YOLOv8 OBB或者YOLOv5 OBB这类模型,那这篇就是为你写的。如果你标的是普通水平框,逻辑也完全兼容,可以顺手转成HBB格式。1. 为什么非要绕一圈:从rolabelimg的XML到YOLO的TXT1.1 这个格式问题到底出在哪rolabelimg本身就是基于labelImg改造的标注工具,所以它的输出带有明显的VOC血统:一个XML文件对应一张图片,里面用object节点存放检测目标,每个目标下面除了常规的bndbox(外接水平矩形)之外,还额外存了一个robndbox节点,里面是旋转框的中心点、宽、高和角度。问题在于,YOLO训练器根本不认XML,它只认和图片同名的txt文件,每行一个目标,类别加坐标,全归一化。于是这两个格式之间横着一条鸿沟:XML是嵌套结构、绝对坐标、保留了旋转角度;txt是平铺结构、归一化坐标、只认点坐标。这个矛盾刚接触的人容易低估,觉得“不就是把cx、cy、w、h除以图像宽高嘛”。但旋转框和水平框不一样,旋转框多了角度,而且还得分清角度定义是哪套坐标系,宽和高谁对应角度那根轴,四角点顺序有没有要求。任何一个细节错了,训练时loss直接起飞,可视化时框歪得离谱。1.2 旋转框检测为什么离不开这个转换有人会问:我标注的明明是一个斜着的飞机,直接用一个水平矩形包起来不行吗?不行。遥感影像里的目标动不动就是几十上百个密集排列,比如港口里并排停靠的船只、停车场里的车辆、农田里的建筑物,如果用水平外接框,两个相邻目标的框会大面积重叠,IoU计算失真,NMS会把原本独立的目标合并掉。所以旋转检测框(OBB)在这些场景几乎是必须的。而YOLO系列从v5开始引入OBB支持,v8做得更成熟,训练时要求标签为class x1 y1 x2 y2 x3 y3 x4 y4。注意这里不是中心点加宽高加角度,而是四个角点的归一化坐标。因为角度回归存在周期性问题,0度和360度在数值上差很远但几何上是同一个框,模型很难学;四点坐标回归相对更温和。所以我们把rolabelimg的XML转成txt,本质上是把“中心点+宽高+角度”这种用角度表达的旋转矩形,换算成“四个点坐标”的表达。1.3 转换的本质:一个坐标系到另一个坐标系这个转换看起来就是套公式,但背后是两套完全不同的坐标约定。rolabelimg里的坐标是基于图像像素坐标系,原点在左上角,x向右,y向下,单位是像素;YOLO的标签是归一化坐标系,同样原点在左上角,但坐标范围在0到1之间,跟图像分辨率无关。中间还涉及一个细节:rolabelimg中的robndbox用的是旋转矩形五参数学表示,而YOLO要的是多边形四角点表示。因此转换任务拆开就是三步:第一步从XML里解析出五参数;第二步根据角度定义计算旋转矩形的四个顶点像素坐标;第三步把四个点分别除以图像宽高做归一化,类别名换成类别ID,按YOLO的格式写进txt。理清楚这个流程后,后面所有问题都是围绕这三个环节展开的。2. 动手前必知:两种格式的字段与坐标系约定2.1 先看懂rolabelimg的XML以实际标注结果为例,一个典型的rolabelimg XML长这样:<annotation> <folder>images</folder> <filename>img_001.jpg</filename> <size> <width>1024</width> <height>768</height> <depth>3</depth> </size> <object> <name>ship</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>300</xmin> <ymin>200</ymin> <xmax>700</xmax> <ymax>500</ymax> </bndbox> <robndbox> <cx>500</cx> <cy>350</cy> <w>600</w> <h>200</h> <angle>-26.57</angle> </robndbox> </object> </annotation>关键节点我拆开讲:filename是图片文件名,转换时先按它找图,找不到就用XML同名文件兜底。size里的width和height是归一化必须的除数,注意深度字段depth容易被漏读,但转格式时用不到。object下可能有多个目标,每个object代表一个框。转换脚本要遍历所有object。bndbox是水平外接框,四角点。它存在的意义是兼容普通目标检测,对于旋转框转换,我们主要看robndbox。robndbox里的cx、cy是旋转框中心点,w是角度对应的那条边的长度,h是另一条边的长度,angle是旋转角度。这是命根子,也是最容易出错的地方。有一个细节值得提醒:不同小版本的rolabelimg保存的XML结构不完全一样。有的版本把angle写在robndbox里,有的版本是往bndbox里加了angle属性,还有的版本robndbox的w和h顺序不同。建议拿到数据先随机打开三五个XML,肉眼确认节点结构再写脚本。别问我为什么强调这个,我吃过亏,写好的脚本一跑就挂,结果是对方给的标注工具版本比较老。2.2 再看懂YOLO的TXT:HBB和OBB的区别普通YOLO水平框的txt格式是:class cx cy w h其中cx、cy是中心点坐标,w、h是框宽高,全部归一化到0到1。这是HBB格式,只能表达轴对齐矩形。YOLOv8 OBB的txt格式是:class x1 y1 x2 y2 x3 y3 x4 y4class是类别ID,后面四组坐标是旋转矩形的四个顶点,同样全部归一化。注意:这一行里没有任何角度字段,模型靠四个点来隐式表达旋转。所以转换时我们必须把cx、cy、w、h、angle这个五参数表示,换算成四角点。一个常见误区是有人直接把cx、cy、w、h归一化后当成OBB的“伪四点”用,只把水平外接框的四个角写进去。这样角度信息完全丢了,复现出来的框是水平的,旋转检测直接变水平检测,之前标注的旋转角度白费。转换时必须用robndbox,不能用bndbox做替代。2.3 角度定义是最大的坑:OpenCV角度与DOTA角度robndbox里的angle到底是什么定义?这一点不搞清楚,所有的公式都是空中楼阁。rolabelimg底层计算旋转框时用的是OpenCV那套约定:角度范围是[-90, 0)(单位是度),表示的是矩形第一条边(也就是w对应的边)和图像x轴正方向之间的夹角,因为图像坐标系y轴向下,负角在视觉上表现为顺时针旋转。举例来说,一个angle=-30度的矩形,它的长边相对于水平方向顺时针转了30度。这个角度定义跟数学教材里的逆时针为正刚好相反,所以直接用math库计算时要特别当心。另一套常见定义是DOTA数据集的约定,角度范围通常是0到180度,逆时针为正,两个角点坐标的排列方式也不一样。很多网上找的转换脚本是从DOTA工具改的,不检查就直接套用,转出来的框要么镜像,要么顶点顺序完全反掉。所以写转换脚本前,先回答一个问题:你的angle是OpenCV定义的负值锐角,还是DOTA定义的正值角?如果是rolabelimg默认导出的,按OpenCV定义处理即可。2.4 输出标签的目录结构YOLO训练时对数据集目录有约定,虽然不是强制的,但最好按标准来:dataset/ ├── images/ │ ├── train/ │ │ ├── img_001.jpg │ │ └── ... │ └── val/ ├── labels/ │ ├── train/ │ │ ├── img_001.txt │ │ └── ... │ └── val/ ├── classes.txt每个txt必须和对应图片同名同目录层级,后缀从.jpg改成.txt。图片在images/train里,标签就要在labels/train里。classes.txt一行一个类别名,顺序就是类别ID顺序,不能随便乱排,不然后面训练出来的模型类别错位。我在转换脚本里会直接把classes.txt生成出来,并提示你检查一遍顺序。因为如果你的原数据里有ship、plane、vehicle三个类,脚本按字母序排出来的是plane、ship、vehicle,ID分别对应0、1、2。如果你之前训练过一版按别的顺序排列的,就得保持旧的classes.txt顺序,否则新旧标签ID对不上,模型直接废掉。3. 核心转换原理:从五参数到四角点的完整推导3.1 五参数怎么算四点:公式与来历从五参数(cx, cy, w, h, angle)计算四个顶点的数学原理不复杂,本质上就是先把矩形在局部坐标系里定义,然后旋转平移到图像坐标系。先说局部坐标系。假设矩形中心在原点,w边沿x轴方向,h边沿y轴方向,不考虑旋转时四个顶点是(-w/2, -h/2)、(w/2, -h/2)、(w/2, h/2)、(-w/2, h/2)。现在把矩形旋转一个角度,也就是把所有顶点乘一个旋转矩阵。图像坐标系下,角度为angle(这里angle是度,先转成弧度theta),旋转矩阵是:| cos(theta) sin(theta) | | -sin(theta) cos(theta) |等等,这里要小心。上面这个旋转矩阵适用于数学坐标系逆时针旋转。但在图像坐标系里,rolabelimg的angle是负角度同时y轴向下,直接用下面这个公式组套是经过很多人验证的:import math def rbox_to_4points(cx, cy, w, h, angle): theta = math.radians(angle) cos_t = math.cos(theta) sin_t = math.sin(theta) x1 = cx - w / 2 * cos_t + h / 2 * sin_t y1 = cy - w / 2 * sin_t - h / 2 * cos_t x2 = cx + w / 2 * cos_t + h / 2 * sin_t y2 = cy + w / 2 * sin_t - h / 2 * cos_t x3 = cx + w / 2 * cos_t - h / 2 * sin_t y3 = cy + w / 2 * sin_t + h / 2 * cos_t x4 = cx - w / 2 * cos_t - h / 2 * sin_t y4 = cy - w / 2 * sin_t + h / 2 * cos_t return [(x1, y1), (x2, y2), (x3, y3), (x4, y4)]如果你懒得记公式,手上有OpenCV的话,更稳妥的做法是直接用cv2.boxPoints:import numpy as np import cv2 def rbox_to_4points_cv(cx, cy, w, h, angle): box = ((cx, cy), (w, h), angle) # OpenCV角度,单位度 points = cv2.boxPoints(box) # 返回4个顶点 return points.tolist()两种方法结果一致。cv2.boxPoints的好处是它内部就是OpenCV同一套角度约定,只要angle是rolabelimg导出的原始角度,基本不会出错。建议你在小样本数据上两套方法都跑一遍,对比结果,确认公式理解无误后再批量跑。3.2 归一化处理和类别映射计算出四个顶点的像素坐标后,归一化就是把每个坐标除以图像的宽或高:norm_points = [(x / img_w, y / img_h) for x, y in points]这里有个容易忽略的细节:图像宽度对应所有x坐标的除数,高度对应所有y坐标的除数,不是用对角线的长度统一除。有些半懂不懂的代码会把四个点用最大坐标值归一化,这是错的。类别映射则分两种情况:如果整个数据集是第一次转换,就直接收集所有XML里的name,去重后按字母序排序,写入classes.txt。后续转换时,每个name去classes.txt里查它的下标,就是类别ID。如果以前已经有classes.txt,那务必以旧的为准,不要重新排序。建议在脚本里加一个参数,要么自动生成classes,要么指定已有classes文件。还有一个细节:同一张图里的同名目标非常多,转换脚本里每张图写完txt后要检查一下,如果某个XML里没有object,那就生成一个空txt。很多YOLO训练流程允许空标签文件存在,但没有对应txt会报错,所以空文件也要生成。3.3 完整转换脚本:可以直接用下面给出一个完整可运行的脚本。我用的是标准库加少量依赖,核心转换部分只依赖math,可视化部分需要opencv-python,如果你不需要可视化可以把那段注释掉。import os import math import xml.etree.ElementTree as ET from collections import defaultdict # ---------- 配置 ---------- XML_DIR = "path/to/xmls" # XML文件夹 IMG_ROOT = "path/to/images" # 图片根目录 LABEL_ROOT = "path/to/labels" # 输出标签根目录 CLASSES_FILE = "path/to/classes.txt" # 类别文件,可自动生成 USE_OLD_CLASSES = False # 是否使用已有的classes.txt # ------------------------- def get_all_xml_files(xml_dir): return [os.path.join(xml_dir, f) for f in os.listdir(xml_dir) if f.endswith(".xml")] def collect_classes(xml_files): class_set = set() for xml_file in xml_files: tree = ET.parse(xml_file) root = tree.getroot() for obj in root.findall("object"): name = obj.findtext("name").strip() class_set.add(name) return sorted(class_set) def load_existing_classes(classes_file): classes = [] with open(classes_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: classes.append(line) return classes def rbox_to_4points(cx, cy, w, h, angle): theta = math.radians(angle) cos_t = math.cos(theta) sin_t = math.sin(theta) x1 = cx - w / 2 * cos_t + h / 2 * sin_t y1 = cy - w / 2 * sin_t - h / 2 * cos_t x2 = cx + w / 2 * cos_t + h / 2 * sin_t y2 = cy + w / 2 * sin_t - h / 2 * cos_t x3 = cx + w / 2 * cos_t - h / 2 * sin_t y3 = cy + w / 2 * sin_t + h / 2 * cos_t x4 = cx - w / 2 * cos_t - h / 2 * sin_t y4 = cy - w / 2 * sin_t + h / 2 * cos_t return [(x1, y1), (x2, y2), (x3, y3), (x4, y4)] def convert_one_xml(xml_file, img_root, label_root, class2id, img_w, img_h): # 这里img_w和img_h从XML的size节点读,避免依赖图片本身 tree = ET.parse(xml_file) root = tree.getroot() filename = root.findtext("filename").strip() if not filename: basename = os.path.basename(xml_file) filename = os.path.splitext(basename)[0] + ".jpg" stem = os.path.splitext(filename)[0] txt_path = os.path.join(label_root, stem + ".txt") lines = [] for obj in root.findall("object"): name = obj.findtext("name").strip() if name not in class2id: print(f"警告: {xml_file} 中发现未注册类别 {name},跳过该目标") continue cls_id = class2id[name] # 优先用robndbox,没有则退回bndbox水平框 robndbox = obj.find("robndbox") if robndbox is not None: cx = float(robndbox.findtext("cx")) cy = float(robndbox.findtext("cy")) w = float(robndbox.findtext("w")) h = float(robndbox.findtext("h")) angle = float(robndbox.findtext("angle")) points = rbox_to_4points(cx, cy, w, h, angle) else: bndbox = obj.find("bndbox") if bndbox is None: print(f"警告: {xml_file} 的object缺少bndbox和robndbox,跳过") continue xmin = float(bndbox.findtext("xmin")) ymin = float(bndbox.findtext("ymin")) xmax = float(bndbox.findtext("xmax")) ymax = float(bndbox.findtext("ymax")) cx = (xmin + xmax) / 2 cy = (ymin + ymax) / 2 w = xmax - xmin h = ymax - ymin angle = 0.0 points = rbox_to_4points(cx, cy, w, h, angle) norm_points = [] for x, y in points: nx = x / img_w ny = y / img_h norm_points.append((nx, ny)) line = str(cls_id) for nx, ny in norm_points: line += f" {nx:.6f} {ny:.6f}" lines.append(line) with open(txt_path, "w", encoding="utf-8") as f: if lines: f.write("\n".join(lines) + "\n") else: f.write("") # 生成空文件,保持标签文件存在 def main(): os.makedirs(LABEL_ROOT, exist_ok=True) xml_files = get_all_xml_files(XML_DIR) print(f"找到 {len(xml_files)} 个XML文件") if USE_OLD_CLASSES and os.path.exists(CLASSES_FILE): classes = load_existing_classes(CLASSES_FILE) else: classes = collect_classes(xml_files) if not classes: print("没有发现任何类别,检查XML文件内容") return with open(CLASSES_FILE, "w", encoding="utf-8") as f: for c in classes: f.write(c + "\n") class2id = {c: i for i, c in enumerate(classes)} print(f"类别映射: {class2id}") for xml_file in xml_files: tree = ET.parse(xml_file) root = tree.getroot() size = root.find("size") img_w = float(size.findtext("width")) img_h = float(size.findtext("height")) convert_one_xml(xml_file, IMG_ROOT, LABEL_ROOT, class2id, img_w, img_h) print("转换完成") if __name__ == "__main__": main()这个脚本我在实际数据上跑过,生成的txt可以直接喂给YOLOv8 OBB训练。有几个设计上的考量说一下:图片宽高优先从XML的size节点读取,不额外打开图片文件。这是因为大批量转换时反复读图很费时间,而XML里的size只要标注工具没写错就是准的。如果你不放心,可以打开一张图核对一下XML里的宽高和真实图是否一致。每个XML的目标循环里,优先取robndbox,取不到就退回bndbox。这样即使你的数据里有少量没标旋转角度的框,脚本也能兜底,不至于整个XML报错中断。输出txt时统一保留6位小数。这个精度对归一化坐标来说足够,不需要保留一大串。空标签也生成文件,避免训练时报“图片没有对应标签文件”的错。3.4 参数说明与运行方式使用前把XML_DIR改成放XML的目录,IMG_ROOT如果只是占位可以不改,脚本没有真正使用它。LABEL_ROOT是输出目录,脚本会自动创建。CLASSES_FILE是类别文件的路径,第一次运行会生成,之后如果要用旧的就设USE_OLD_CLASSES为True。运行方式很简单:python xml2yolo_obb.py运行结束后到LABEL_ROOT随便打开几个txt,应该能看到类似下面的内容:2 0.3024 0.2548 0.4320 0.1200 0.5102 0.2017 0.3806 0.3365 0 0.1000 0.5000 0.2000 0.4800 0.2100 0.5500 0.1100 0.5700每行开头的整数是类别ID,后面8个浮点数依次是四个顶点的归一化x、y。如果输出长这样,格式层面就对了。这里我遇到过一个问题:某些标注工具保存的angle是字符串形式,比如“-26.57”,但有些版本会存成“-0.4636”这种弧度值。脚本里float转换不会报错,但角度差了一个数量级,转出来的框全错。建议在批量转换前先写一个检查逻辑:统计所有angle的取值范围,如果绝对值都小于3,说明可能是弧度,需要乘以180/π转成度再算。4. 验证这一步千万别省:可视化核对转换结果4.1 可视化脚本:把txt画回原图格式转换这件事,最容易出现“数据看起来没问题,训练出来完全不对”的情况。所以强烈建议转换完先可视化一批样本,把txt里的四角点画回到原图上,和rolabelimg里原始标注对比。下面是一段快速可视化代码,用OpenCV画旋转框:import cv2 import numpy as np def draw_obb_from_txt(image_path, txt_path, classes, save_path=None): img = cv2.imread(image_path) h, w = img.shape[:2] with open(txt_path, "r", encoding="utf-8") as f: lines = f.readlines() colors = [(0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 255, 255), (255, 255, 0)] for line in lines: parts = line.strip().split() if len(parts) < 9: continue cls_id = int(parts[0]) pts = np.array([[float(parts[i]) * w, float(parts[i + 1]) * h] for i in range(1, 9, 2)], dtype=np.int32).reshape(-1, 2) cv2.polylines(img, [pts], isClosed=True, color=colors[cls_id % len(colors)], thickness=2) cx = int(pts[:, 0].mean()) cy = int(pts[:, 1].mean()) cv2.putText(img, classes[cls_id], (cx, cy), cv2.FONT_HERSHEY_SIMPLEX, 0.8, colors[cls_id % len(colors)], 2) if save_path: cv2.imwrite(save_path, img) else: cv2.imshow("check", img) cv2.waitKey(0) cv2.destroyAllWindows()使用的时候传入原图路径、转换后的txt路径和classes列表,跑个十几张典型样本,一张张看框是否贴合目标。4.2 怎么看验证结果看可视化结果时要重点看三件事:第一,框的角度对不对。比如一艘斜靠岸边的船,转换后的框应该和船身方向一致,不能是水平的也不能是镜像的。如果角度反了,框的四个顶点会在目标外侧翻转,一眼就能发现。第二,框的尺寸对不对。长条形目标转换后的框仍然应该是长条形,w和h不能互换。如果输出框变成了转90度的形状,说明w、h和angle的对应关系没弄对。第三,框在边缘目标上的表现。目标贴近图像边界时,旋转框的顶点可能落在图像外面,这是正常现象,YOLO训练能容忍略微越界的坐标。但如果你看到框的整体中心偏移得离谱,那多半是XML里的cx、cy读错了,或者图片宽高算错导致归一化除错了。4.3 批量抽检的建议不要只验证一张,也不要每张都看。按我的习惯,比例大概在5%到10%左右,而且要有意识地挑选不同类别、不同旋转角度、不同位置的目标。比如一个遥感数据集有1000张,抽50张,覆盖所有类别,每张图里框多且密集,这样的验证就很有代表性。如果批量可视化不方便,可以做一个更粗糙但有效的自动化检查:统计所有txt里坐标值的范围。正常情况下,绝大多数点在0到1之间,如果大量出现负数或者超过1,说明边缘目标很多,或者转换有问题。还可以输出每个txt的行数和对应XML的object数量做对比,数量不一致的文件重点排查。我在项目里就是这么干的:先自动统计,再看十几张图,确认没问题后才放心把数据交给训练脚本。这一步省下的时间,远大于它花掉的时间。5. 踩坑实录与常见问题速查5.1 角度是弧度还是角度这是出现频率最高的坑。rolabelimg保存的robndbox的angle大多数情况下是角度,单位是度,OpenCV定义,范围[-90, 0)。但有些人拿到的数据经过二次处理,或者标注工具版本不同,angle可能是弧度。判断方法很简单:把所有XML的angle打印出来,看看数值分布。如果集中在-90到0之间那是角度;如果集中在负的零点几到1之间,那多半是弧度;如果出现0到180的正值,可能是DOTA约定。如果是弧度,在调用rbox_to_4points之前先做一次转换:angle_deg = math.degrees(angle_rad)如果你已经把错误的数据转完并训练了一版模型,也别急,只要原始XML还在,重新用修正后的脚本转一遍,重新训练就行。原始标注数据永远不要丢,这是吃一堑长一智的教训。5.2 四角点顺序不对或者图形交叉YOLO OBB对四点的顺序有要求吗?严格说,YOLOv8官方训练代码会重新处理和校验,但如果你要拿去别的框架,或者做数据可视化、转成DOTA格式做评测,四点的顺序就必须规范。DOTA官方格式里四个点是按顺时针排列的,从哪个顶点开始没有硬性规定,但必须首尾相连不交叉。如果你发现可视化时框的边交叉了,也就是画出来像一个蝴蝶结而不是矩形,那说明四点的连接顺序有问题。可以用下面的简单算法把所有点按极角排序,得到一个规范的顺时针顺序:import numpy as np def sort_clockwise(points): pts = np.array(points) cx, cy = pts.mean(axis=0) angles = np.arctan2(pts[:, 1] - cy, pts[:, 0] - cx) idx = np.argsort(angles) # 按角度升序,图像坐标下得到顺时针/逆时针?自己调一次方向 return pts[idx].tolist()注意:图像坐标y向下,arctan2排序出来的方向可能是逆时针,需要结合可视化结果微调一下(反转列表即可)。这属于“排序约定”的问题,不同工具之间经常不一致,我的建议永远是以可视化结果为准,不要迷信某段代码说自己是“顺时针”。5.3 水平框与旋转框混用如果你的标注人员在标注时偷懒,一部分目标标了旋转框,一部分目标只标了水平框,那么XML里同一个object下robndbox和bndbox都存在,但你无法确定哪些目标是真的旋转框、哪些是水平框“碰巧”也生成了外接矩形。我的处理方式是用面积和长宽双条件判断:把robndbox计算的w和h与bndbox计算的宽高对比,如果两者接近(比如面积比例在0.9到1.1之间),说明这个目标本来是水平框,robndbox只是外接的一个等价表示;如果比例明显小于1,说明robndbox比bndbox小得多,是真正的旋转框。实际数据里,旋转框的robndbox面积通常显著小于bndbox,这个特征比较可靠。不过在转换脚本里我仍然维持“优先robndbox”的策略,因为即使目标是水平框,robndbox表达也是正确的旋转框特例,角度为0或接近0,不影响训练。5.4 类别映射错位多类别数据集最容易翻车的地方。第一版脚本按字母序生成classes.txt,第二版如果有人在前面加了一个类,所有类别ID全部后移一位,旧标签全部错位,模型输出完全混乱。这种情况我建议:在任何时候都不要手动改classes.txt的顺序,新增类别永远追加到文件末尾。同时在转换脚本里加一个校验,如果遇到类别不在classes.txt里,不要静默忽略,直接报错或者打印警告,让你知道数据里出现了新类别。5.5 多源数据合并时的约定差异这次我处理的是一个混合来源的数据集,一部分是rolabelimg标的,一部分是从开源遥感数据转出来的,保存格式不完全一样。比如有的XML里robndbox的angle写的是90度到180度之间的正值,有的数据集里w和h的语义和rolabelimg反过来。这些数据合并前必须先统一到同一种坐标约定,我最后是把所有数据都转成DOTA四点格式作为中间格式,再统一转成YOLO OBB的txt,这样两边约定不一致的问题就被中间格式消化掉了。5.6 常见问题速查表现象可能原因解决办法角度反向,目标框歪到另一侧角度定义混淆,OpenCV负角被当成DOTA正角修正角度定义,保持原始负角框变瘦或者变胖,看上去转了90度w和h与angle的对应关系错了确认angle对应w,h是垂直边坐标全部接近0或全部接近1归一化时用错了图像宽高检查XML的width/height,不要用txt顺序错位画出的框交叉成蝴蝶结四角点顺序错乱用极角排序重新排列四点训练时loss为NaN标签有Inf或者过大的异常坐标检查XML里是否出现异常框某些图片没有txt文件图片名和XML名对不上用XML的filename读取,并做同名兜底全部类别ID都是0classes.txt顺序和预期不一致用旧的classes.txt,将旧类别文件保留前三项是我在多次转换过程中真实遇到过的,尤其是w和h对应关系,因为不同版本的rolabelimg在界面上显示的“长边”和“短边”有时候会让标注者产生误解,导致标出来的angle对应的边和我们脚本里解析的w不一致。遇到这种情况,不要盲目改脚本,先打开rolabelimg重新看一眼那个目标原始框的朝向,再用数值计算验证一遍哪个假设是对的。结尾:一点个人经验做了这么多轮xml转txt,我的习惯是保留一个“转换配置”的备忘录,把数据来源、标注工具版本、角度约定、w/h语义、classes顺序这五条信息记下来。因为这类数据转换往往不是一次性工作,过两周可能又新增了一批标注数据要合并,到时候如果没有备注,你真的会忘记当时是怎么处理的。另外建议大家把原始XML永远保留一份,转换后的txt只是中间产物,什么时候想换格式、换模型、做评测,都还要回到原始标注上去。之前有人直接把XML删了,后来要出DOTA格式提交结果,只能对照着txt反推XML,折腾得够呛。最后再分享一个小技巧:转换完别急着删脚本,把脚本里的核心函数单独抽出来,留着下次复用。我这次就是直接把之前的转换函数拿过来改了个classes路径,五分钟搞定新数据,批处理跑完顺手可视化抽检一遍,整个过程稳稳当当。工具链这种东西,越沉淀越好用。
做旋转目标检测的都知道,标注完一批数据只是万里长征第一步,最磨人的反而是格式转换。rolabelimg保存的是Pascal VOC风格的XML,里面还夹带一个robndbox旋转框节点;而YOLO系训练要的是归一化的TXT,如果跑OBB还得是四点坐标。这个“xml转txt”的需求我前前后后处理过好多次,今天把完整的踩坑过程和可直接抄的脚本整理出来,希望能帮正在做遥感、航拍、版面检测这类旋转目标的同学省下半天时间。这事适合谁呢?一句话:只要你的数据是用rolabelimg标的,而训练打算用YOLOv8 OBB或者YOLOv5 OBB这类模型,那这篇就是为你写的。如果你标的是普通水平框,逻辑也完全兼容,可以顺手转成HBB格式。1. 为什么非要绕一圈:从rolabelimg的XML到YOLO的TXT1.1 这个格式问题到底出在哪rolabelimg本身就是基于labelImg改造的标注工具,所以它的输出带有明显的VOC血统:一个XML文件对应一张图片,里面用object节点存放检测目标,每个目标下面除了常规的bndbox(外接水平矩形)之外,还额外存了一个robndbox节点,里面是旋转框的中心点、宽、高和角度。问题在于,YOLO训练器根本不认XML,它只认和图片同名的txt文件,每行一个目标,类别加坐标,全归一化。于是这两个格式之间横着一条鸿沟:XML是嵌套结构、绝对坐标、保留了旋转角度;txt是平铺结构、归一化坐标、只认点坐标。这个矛盾刚接触的人容易低估,觉得“不就是把cx、cy、w、h除以图像宽高嘛”。但旋转框和水平框不一样,旋转框多了角度,而且还得分清角度定义是哪套坐标系,宽和高谁对应角度那根轴,四角点顺序有没有要求。任何一个细节错了,训练时loss直接起飞,可视化时框歪得离谱。1.2 旋转框检测为什么离不开这个转换有人会问:我标注的明明是一个斜着的飞机,直接用一个水平矩形包起来不行吗?不行。遥感影像里的目标动不动就是几十上百个密集排列,比如港口里并排停靠的船只、停车场里的车辆、农田里的建筑物,如果用水平外接框,两个相邻目标的框会大面积重叠,IoU计算失真,NMS会把原本独立的目标合并掉。所以旋转检测框(OBB)在这些场景几乎是必须的。而YOLO系列从v5开始引入OBB支持,v8做得更成熟,训练时要求标签为class x1 y1 x2 y2 x3 y3 x4 y4。注意这里不是中心点加宽高加角度,而是四个角点的归一化坐标。因为角度回归存在周期性问题,0度和360度在数值上差很远但几何上是同一个框,模型很难学;四点坐标回归相对更温和。所以我们把rolabelimg的XML转成txt,本质上是把“中心点+宽高+角度”这种用角度表达的旋转矩形,换算成“四个点坐标”的表达。1.3 转换的本质:一个坐标系到另一个坐标系这个转换看起来就是套公式,但背后是两套完全不同的坐标约定。rolabelimg里的坐标是基于图像像素坐标系,原点在左上角,x向右,y向下,单位是像素;YOLO的标签是归一化坐标系,同样原点在左上角,但坐标范围在0到1之间,跟图像分辨率无关。中间还涉及一个细节:rolabelimg中的robndbox用的是旋转矩形五参数学表示,而YOLO要的是多边形四角点表示。因此转换任务拆开就是三步:第一步从XML里解析出五参数;第二步根据角度定义计算旋转矩形的四个顶点像素坐标;第三步把四个点分别除以图像宽高做归一化,类别名换成类别ID,按YOLO的格式写进txt。理清楚这个流程后,后面所有问题都是围绕这三个环节展开的。2. 动手前必知:两种格式的字段与坐标系约定2.1 先看懂rolabelimg的XML以实际标注结果为例,一个典型的rolabelimg XML长这样:<annotation> <folder>images</folder> <filename>img_001.jpg</filename> <size> <width>1024</width> <height>768</height> <depth>3</depth> </size> <object> <name>ship</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>300</xmin> <ymin>200</ymin> <xmax>700</xmax> <ymax>500</ymax> </bndbox> <robndbox> <cx>500</cx> <cy>350</cy> <w>600</w> <h>200</h> <angle>-26.57</angle> </robndbox> </object> </annotation>关键节点我拆开讲:filename是图片文件名,转换时先按它找图,找不到就用XML同名文件兜底。size里的width和height是归一化必须的除数,注意深度字段depth容易被漏读,但转格式时用不到。object下可能有多个目标,每个object代表一个框。转换脚本要遍历所有object。bndbox是水平外接框,四角点。它存在的意义是兼容普通目标检测,对于旋转框转换,我们主要看robndbox。robndbox里的cx、cy是旋转框中心点,w是角度对应的那条边的长度,h是另一条边的长度,angle是旋转角度。这是命根子,也是最容易出错的地方。有一个细节值得提醒:不同小版本的rolabelimg保存的XML结构不完全一样。有的版本把angle写在robndbox里,有的版本是往bndbox里加了angle属性,还有的版本robndbox的w和h顺序不同。建议拿到数据先随机打开三五个XML,肉眼确认节点结构再写脚本。别问我为什么强调这个,我吃过亏,写好的脚本一跑就挂,结果是对方给的标注工具版本比较老。2.2 再看懂YOLO的TXT:HBB和OBB的区别普通YOLO水平框的txt格式是:class cx cy w h其中cx、cy是中心点坐标,w、h是框宽高,全部归一化到0到1。这是HBB格式,只能表达轴对齐矩形。YOLOv8 OBB的txt格式是:class x1 y1 x2 y2 x3 y3 x4 y4class是类别ID,后面四组坐标是旋转矩形的四个顶点,同样全部归一化。注意:这一行里没有任何角度字段,模型靠四个点来隐式表达旋转。所以转换时我们必须把cx、cy、w、h、angle这个五参数表示,换算成四角点。一个常见误区是有人直接把cx、cy、w、h归一化后当成OBB的“伪四点”用,只把水平外接框的四个角写进去。这样角度信息完全丢了,复现出来的框是水平的,旋转检测直接变水平检测,之前标注的旋转角度白费。转换时必须用robndbox,不能用bndbox做替代。2.3 角度定义是最大的坑:OpenCV角度与DOTA角度robndbox里的angle到底是什么定义?这一点不搞清楚,所有的公式都是空中楼阁。rolabelimg底层计算旋转框时用的是OpenCV那套约定:角度范围是[-90, 0)(单位是度),表示的是矩形第一条边(也就是w对应的边)和图像x轴正方向之间的夹角,因为图像坐标系y轴向下,负角在视觉上表现为顺时针旋转。举例来说,一个angle=-30度的矩形,它的长边相对于水平方向顺时针转了30度。这个角度定义跟数学教材里的逆时针为正刚好相反,所以直接用math库计算时要特别当心。另一套常见定义是DOTA数据集的约定,角度范围通常是0到180度,逆时针为正,两个角点坐标的排列方式也不一样。很多网上找的转换脚本是从DOTA工具改的,不检查就直接套用,转出来的框要么镜像,要么顶点顺序完全反掉。所以写转换脚本前,先回答一个问题:你的angle是OpenCV定义的负值锐角,还是DOTA定义的正值角?如果是rolabelimg默认导出的,按OpenCV定义处理即可。2.4 输出标签的目录结构YOLO训练时对数据集目录有约定,虽然不是强制的,但最好按标准来:dataset/ ├── images/ │ ├── train/ │ │ ├── img_001.jpg │ │ └── ... │ └── val/ ├── labels/ │ ├── train/ │ │ ├── img_001.txt │ │ └── ... │ └── val/ ├── classes.txt每个txt必须和对应图片同名同目录层级,后缀从.jpg改成.txt。图片在images/train里,标签就要在labels/train里。classes.txt一行一个类别名,顺序就是类别ID顺序,不能随便乱排,不然后面训练出来的模型类别错位。我在转换脚本里会直接把classes.txt生成出来,并提示你检查一遍顺序。因为如果你的原数据里有ship、plane、vehicle三个类,脚本按字母序排出来的是plane、ship、vehicle,ID分别对应0、1、2。如果你之前训练过一版按别的顺序排列的,就得保持旧的classes.txt顺序,否则新旧标签ID对不上,模型直接废掉。3. 核心转换原理:从五参数到四角点的完整推导3.1 五参数怎么算四点:公式与来历从五参数(cx, cy, w, h, angle)计算四个顶点的数学原理不复杂,本质上就是先把矩形在局部坐标系里定义,然后旋转平移到图像坐标系。先说局部坐标系。假设矩形中心在原点,w边沿x轴方向,h边沿y轴方向,不考虑旋转时四个顶点是(-w/2, -h/2)、(w/2, -h/2)、(w/2, h/2)、(-w/2, h/2)。现在把矩形旋转一个角度,也就是把所有顶点乘一个旋转矩阵。图像坐标系下,角度为angle(这里angle是度,先转成弧度theta),旋转矩阵是:| cos(theta) sin(theta) | | -sin(theta) cos(theta) |等等,这里要小心。上面这个旋转矩阵适用于数学坐标系逆时针旋转。但在图像坐标系里,rolabelimg的angle是负角度同时y轴向下,直接用下面这个公式组套是经过很多人验证的:import math def rbox_to_4points(cx, cy, w, h, angle): theta = math.radians(angle) cos_t = math.cos(theta) sin_t = math.sin(theta) x1 = cx - w / 2 * cos_t + h / 2 * sin_t y1 = cy - w / 2 * sin_t - h / 2 * cos_t x2 = cx + w / 2 * cos_t + h / 2 * sin_t y2 = cy + w / 2 * sin_t - h / 2 * cos_t x3 = cx + w / 2 * cos_t - h / 2 * sin_t y3 = cy + w / 2 * sin_t + h / 2 * cos_t x4 = cx - w / 2 * cos_t - h / 2 * sin_t y4 = cy - w / 2 * sin_t + h / 2 * cos_t return [(x1, y1), (x2, y2), (x3, y3), (x4, y4)]如果你懒得记公式,手上有OpenCV的话,更稳妥的做法是直接用cv2.boxPoints:import numpy as np import cv2 def rbox_to_4points_cv(cx, cy, w, h, angle): box = ((cx, cy), (w, h), angle) # OpenCV角度,单位度 points = cv2.boxPoints(box) # 返回4个顶点 return points.tolist()两种方法结果一致。cv2.boxPoints的好处是它内部就是OpenCV同一套角度约定,只要angle是rolabelimg导出的原始角度,基本不会出错。建议你在小样本数据上两套方法都跑一遍,对比结果,确认公式理解无误后再批量跑。3.2 归一化处理和类别映射计算出四个顶点的像素坐标后,归一化就是把每个坐标除以图像的宽或高:norm_points = [(x / img_w, y / img_h) for x, y in points]这里有个容易忽略的细节:图像宽度对应所有x坐标的除数,高度对应所有y坐标的除数,不是用对角线的长度统一除。有些半懂不懂的代码会把四个点用最大坐标值归一化,这是错的。类别映射则分两种情况:如果整个数据集是第一次转换,就直接收集所有XML里的name,去重后按字母序排序,写入classes.txt。后续转换时,每个name去classes.txt里查它的下标,就是类别ID。如果以前已经有classes.txt,那务必以旧的为准,不要重新排序。建议在脚本里加一个参数,要么自动生成classes,要么指定已有classes文件。还有一个细节:同一张图里的同名目标非常多,转换脚本里每张图写完txt后要检查一下,如果某个XML里没有object,那就生成一个空txt。很多YOLO训练流程允许空标签文件存在,但没有对应txt会报错,所以空文件也要生成。3.3 完整转换脚本:可以直接用下面给出一个完整可运行的脚本。我用的是标准库加少量依赖,核心转换部分只依赖math,可视化部分需要opencv-python,如果你不需要可视化可以把那段注释掉。import os import math import xml.etree.ElementTree as ET from collections import defaultdict # ---------- 配置 ---------- XML_DIR = "path/to/xmls" # XML文件夹 IMG_ROOT = "path/to/images" # 图片根目录 LABEL_ROOT = "path/to/labels" # 输出标签根目录 CLASSES_FILE = "path/to/classes.txt" # 类别文件,可自动生成 USE_OLD_CLASSES = False # 是否使用已有的classes.txt # ------------------------- def get_all_xml_files(xml_dir): return [os.path.join(xml_dir, f) for f in os.listdir(xml_dir) if f.endswith(".xml")] def collect_classes(xml_files): class_set = set() for xml_file in xml_files: tree = ET.parse(xml_file) root = tree.getroot() for obj in root.findall("object"): name = obj.findtext("name").strip() class_set.add(name) return sorted(class_set) def load_existing_classes(classes_file): classes = [] with open(classes_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: classes.append(line) return classes def rbox_to_4points(cx, cy, w, h, angle): theta = math.radians(angle) cos_t = math.cos(theta) sin_t = math.sin(theta) x1 = cx - w / 2 * cos_t + h / 2 * sin_t y1 = cy - w / 2 * sin_t - h / 2 * cos_t x2 = cx + w / 2 * cos_t + h / 2 * sin_t y2 = cy + w / 2 * sin_t - h / 2 * cos_t x3 = cx + w / 2 * cos_t - h / 2 * sin_t y3 = cy + w / 2 * sin_t + h / 2 * cos_t x4 = cx - w / 2 * cos_t - h / 2 * sin_t y4 = cy - w / 2 * sin_t + h / 2 * cos_t return [(x1, y1), (x2, y2), (x3, y3), (x4, y4)] def convert_one_xml(xml_file, img_root, label_root, class2id, img_w, img_h): # 这里img_w和img_h从XML的size节点读,避免依赖图片本身 tree = ET.parse(xml_file) root = tree.getroot() filename = root.findtext("filename").strip() if not filename: basename = os.path.basename(xml_file) filename = os.path.splitext(basename)[0] + ".jpg" stem = os.path.splitext(filename)[0] txt_path = os.path.join(label_root, stem + ".txt") lines = [] for obj in root.findall("object"): name = obj.findtext("name").strip() if name not in class2id: print(f"警告: {xml_file} 中发现未注册类别 {name},跳过该目标") continue cls_id = class2id[name] # 优先用robndbox,没有则退回bndbox水平框 robndbox = obj.find("robndbox") if robndbox is not None: cx = float(robndbox.findtext("cx")) cy = float(robndbox.findtext("cy")) w = float(robndbox.findtext("w")) h = float(robndbox.findtext("h")) angle = float(robndbox.findtext("angle")) points = rbox_to_4points(cx, cy, w, h, angle) else: bndbox = obj.find("bndbox") if bndbox is None: print(f"警告: {xml_file} 的object缺少bndbox和robndbox,跳过") continue xmin = float(bndbox.findtext("xmin")) ymin = float(bndbox.findtext("ymin")) xmax = float(bndbox.findtext("xmax")) ymax = float(bndbox.findtext("ymax")) cx = (xmin + xmax) / 2 cy = (ymin + ymax) / 2 w = xmax - xmin h = ymax - ymin angle = 0.0 points = rbox_to_4points(cx, cy, w, h, angle) norm_points = [] for x, y in points: nx = x / img_w ny = y / img_h norm_points.append((nx, ny)) line = str(cls_id) for nx, ny in norm_points: line += f" {nx:.6f} {ny:.6f}" lines.append(line) with open(txt_path, "w", encoding="utf-8") as f: if lines: f.write("\n".join(lines) + "\n") else: f.write("") # 生成空文件,保持标签文件存在 def main(): os.makedirs(LABEL_ROOT, exist_ok=True) xml_files = get_all_xml_files(XML_DIR) print(f"找到 {len(xml_files)} 个XML文件") if USE_OLD_CLASSES and os.path.exists(CLASSES_FILE): classes = load_existing_classes(CLASSES_FILE) else: classes = collect_classes(xml_files) if not classes: print("没有发现任何类别,检查XML文件内容") return with open(CLASSES_FILE, "w", encoding="utf-8") as f: for c in classes: f.write(c + "\n") class2id = {c: i for i, c in enumerate(classes)} print(f"类别映射: {class2id}") for xml_file in xml_files: tree = ET.parse(xml_file) root = tree.getroot() size = root.find("size") img_w = float(size.findtext("width")) img_h = float(size.findtext("height")) convert_one_xml(xml_file, IMG_ROOT, LABEL_ROOT, class2id, img_w, img_h) print("转换完成") if __name__ == "__main__": main()这个脚本我在实际数据上跑过,生成的txt可以直接喂给YOLOv8 OBB训练。有几个设计上的考量说一下:图片宽高优先从XML的size节点读取,不额外打开图片文件。这是因为大批量转换时反复读图很费时间,而XML里的size只要标注工具没写错就是准的。如果你不放心,可以打开一张图核对一下XML里的宽高和真实图是否一致。每个XML的目标循环里,优先取robndbox,取不到就退回bndbox。这样即使你的数据里有少量没标旋转角度的框,脚本也能兜底,不至于整个XML报错中断。输出txt时统一保留6位小数。这个精度对归一化坐标来说足够,不需要保留一大串。空标签也生成文件,避免训练时报“图片没有对应标签文件”的错。3.4 参数说明与运行方式使用前把XML_DIR改成放XML的目录,IMG_ROOT如果只是占位可以不改,脚本没有真正使用它。LABEL_ROOT是输出目录,脚本会自动创建。CLASSES_FILE是类别文件的路径,第一次运行会生成,之后如果要用旧的就设USE_OLD_CLASSES为True。运行方式很简单:python xml2yolo_obb.py运行结束后到LABEL_ROOT随便打开几个txt,应该能看到类似下面的内容:2 0.3024 0.2548 0.4320 0.1200 0.5102 0.2017 0.3806 0.3365 0 0.1000 0.5000 0.2000 0.4800 0.2100 0.5500 0.1100 0.5700每行开头的整数是类别ID,后面8个浮点数依次是四个顶点的归一化x、y。如果输出长这样,格式层面就对了。这里我遇到过一个问题:某些标注工具保存的angle是字符串形式,比如“-26.57”,但有些版本会存成“-0.4636”这种弧度值。脚本里float转换不会报错,但角度差了一个数量级,转出来的框全错。建议在批量转换前先写一个检查逻辑:统计所有angle的取值范围,如果绝对值都小于3,说明可能是弧度,需要乘以180/π转成度再算。4. 验证这一步千万别省:可视化核对转换结果4.1 可视化脚本:把txt画回原图格式转换这件事,最容易出现“数据看起来没问题,训练出来完全不对”的情况。所以强烈建议转换完先可视化一批样本,把txt里的四角点画回到原图上,和rolabelimg里原始标注对比。下面是一段快速可视化代码,用OpenCV画旋转框:import cv2 import numpy as np def draw_obb_from_txt(image_path, txt_path, classes, save_path=None): img = cv2.imread(image_path) h, w = img.shape[:2] with open(txt_path, "r", encoding="utf-8") as f: lines = f.readlines() colors = [(0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 255, 255), (255, 255, 0)] for line in lines: parts = line.strip().split() if len(parts) < 9: continue cls_id = int(parts[0]) pts = np.array([[float(parts[i]) * w, float(parts[i + 1]) * h] for i in range(1, 9, 2)], dtype=np.int32).reshape(-1, 2) cv2.polylines(img, [pts], isClosed=True, color=colors[cls_id % len(colors)], thickness=2) cx = int(pts[:, 0].mean()) cy = int(pts[:, 1].mean()) cv2.putText(img, classes[cls_id], (cx, cy), cv2.FONT_HERSHEY_SIMPLEX, 0.8, colors[cls_id % len(colors)], 2) if save_path: cv2.imwrite(save_path, img) else: cv2.imshow("check", img) cv2.waitKey(0) cv2.destroyAllWindows()使用的时候传入原图路径、转换后的txt路径和classes列表,跑个十几张典型样本,一张张看框是否贴合目标。4.2 怎么看验证结果看可视化结果时要重点看三件事:第一,框的角度对不对。比如一艘斜靠岸边的船,转换后的框应该和船身方向一致,不能是水平的也不能是镜像的。如果角度反了,框的四个顶点会在目标外侧翻转,一眼就能发现。第二,框的尺寸对不对。长条形目标转换后的框仍然应该是长条形,w和h不能互换。如果输出框变成了转90度的形状,说明w、h和angle的对应关系没弄对。第三,框在边缘目标上的表现。目标贴近图像边界时,旋转框的顶点可能落在图像外面,这是正常现象,YOLO训练能容忍略微越界的坐标。但如果你看到框的整体中心偏移得离谱,那多半是XML里的cx、cy读错了,或者图片宽高算错导致归一化除错了。4.3 批量抽检的建议不要只验证一张,也不要每张都看。按我的习惯,比例大概在5%到10%左右,而且要有意识地挑选不同类别、不同旋转角度、不同位置的目标。比如一个遥感数据集有1000张,抽50张,覆盖所有类别,每张图里框多且密集,这样的验证就很有代表性。如果批量可视化不方便,可以做一个更粗糙但有效的自动化检查:统计所有txt里坐标值的范围。正常情况下,绝大多数点在0到1之间,如果大量出现负数或者超过1,说明边缘目标很多,或者转换有问题。还可以输出每个txt的行数和对应XML的object数量做对比,数量不一致的文件重点排查。我在项目里就是这么干的:先自动统计,再看十几张图,确认没问题后才放心把数据交给训练脚本。这一步省下的时间,远大于它花掉的时间。5. 踩坑实录与常见问题速查5.1 角度是弧度还是角度这是出现频率最高的坑。rolabelimg保存的robndbox的angle大多数情况下是角度,单位是度,OpenCV定义,范围[-90, 0)。但有些人拿到的数据经过二次处理,或者标注工具版本不同,angle可能是弧度。判断方法很简单:把所有XML的angle打印出来,看看数值分布。如果集中在-90到0之间那是角度;如果集中在负的零点几到1之间,那多半是弧度;如果出现0到180的正值,可能是DOTA约定。如果是弧度,在调用rbox_to_4points之前先做一次转换:angle_deg = math.degrees(angle_rad)如果你已经把错误的数据转完并训练了一版模型,也别急,只要原始XML还在,重新用修正后的脚本转一遍,重新训练就行。原始标注数据永远不要丢,这是吃一堑长一智的教训。5.2 四角点顺序不对或者图形交叉YOLO OBB对四点的顺序有要求吗?严格说,YOLOv8官方训练代码会重新处理和校验,但如果你要拿去别的框架,或者做数据可视化、转成DOTA格式做评测,四点的顺序就必须规范。DOTA官方格式里四个点是按顺时针排列的,从哪个顶点开始没有硬性规定,但必须首尾相连不交叉。如果你发现可视化时框的边交叉了,也就是画出来像一个蝴蝶结而不是矩形,那说明四点的连接顺序有问题。可以用下面的简单算法把所有点按极角排序,得到一个规范的顺时针顺序:import numpy as np def sort_clockwise(points): pts = np.array(points) cx, cy = pts.mean(axis=0) angles = np.arctan2(pts[:, 1] - cy, pts[:, 0] - cx) idx = np.argsort(angles) # 按角度升序,图像坐标下得到顺时针/逆时针?自己调一次方向 return pts[idx].tolist()注意:图像坐标y向下,arctan2排序出来的方向可能是逆时针,需要结合可视化结果微调一下(反转列表即可)。这属于“排序约定”的问题,不同工具之间经常不一致,我的建议永远是以可视化结果为准,不要迷信某段代码说自己是“顺时针”。5.3 水平框与旋转框混用如果你的标注人员在标注时偷懒,一部分目标标了旋转框,一部分目标只标了水平框,那么XML里同一个object下robndbox和bndbox都存在,但你无法确定哪些目标是真的旋转框、哪些是水平框“碰巧”也生成了外接矩形。我的处理方式是用面积和长宽双条件判断:把robndbox计算的w和h与bndbox计算的宽高对比,如果两者接近(比如面积比例在0.9到1.1之间),说明这个目标本来是水平框,robndbox只是外接的一个等价表示;如果比例明显小于1,说明robndbox比bndbox小得多,是真正的旋转框。实际数据里,旋转框的robndbox面积通常显著小于bndbox,这个特征比较可靠。不过在转换脚本里我仍然维持“优先robndbox”的策略,因为即使目标是水平框,robndbox表达也是正确的旋转框特例,角度为0或接近0,不影响训练。5.4 类别映射错位多类别数据集最容易翻车的地方。第一版脚本按字母序生成classes.txt,第二版如果有人在前面加了一个类,所有类别ID全部后移一位,旧标签全部错位,模型输出完全混乱。这种情况我建议:在任何时候都不要手动改classes.txt的顺序,新增类别永远追加到文件末尾。同时在转换脚本里加一个校验,如果遇到类别不在classes.txt里,不要静默忽略,直接报错或者打印警告,让你知道数据里出现了新类别。5.5 多源数据合并时的约定差异这次我处理的是一个混合来源的数据集,一部分是rolabelimg标的,一部分是从开源遥感数据转出来的,保存格式不完全一样。比如有的XML里robndbox的angle写的是90度到180度之间的正值,有的数据集里w和h的语义和rolabelimg反过来。这些数据合并前必须先统一到同一种坐标约定,我最后是把所有数据都转成DOTA四点格式作为中间格式,再统一转成YOLO OBB的txt,这样两边约定不一致的问题就被中间格式消化掉了。5.6 常见问题速查表现象可能原因解决办法角度反向,目标框歪到另一侧角度定义混淆,OpenCV负角被当成DOTA正角修正角度定义,保持原始负角框变瘦或者变胖,看上去转了90度w和h与angle的对应关系错了确认angle对应w,h是垂直边坐标全部接近0或全部接近1归一化时用错了图像宽高检查XML的width/height,不要用txt顺序错位画出的框交叉成蝴蝶结四角点顺序错乱用极角排序重新排列四点训练时loss为NaN标签有Inf或者过大的异常坐标检查XML里是否出现异常框某些图片没有txt文件图片名和XML名对不上用XML的filename读取,并做同名兜底全部类别ID都是0classes.txt顺序和预期不一致用旧的classes.txt,将旧类别文件保留前三项是我在多次转换过程中真实遇到过的,尤其是w和h对应关系,因为不同版本的rolabelimg在界面上显示的“长边”和“短边”有时候会让标注者产生误解,导致标出来的angle对应的边和我们脚本里解析的w不一致。遇到这种情况,不要盲目改脚本,先打开rolabelimg重新看一眼那个目标原始框的朝向,再用数值计算验证一遍哪个假设是对的。结尾:一点个人经验做了这么多轮xml转txt,我的习惯是保留一个“转换配置”的备忘录,把数据来源、标注工具版本、角度约定、w/h语义、classes顺序这五条信息记下来。因为这类数据转换往往不是一次性工作,过两周可能又新增了一批标注数据要合并,到时候如果没有备注,你真的会忘记当时是怎么处理的。另外建议大家把原始XML永远保留一份,转换后的txt只是中间产物,什么时候想换格式、换模型、做评测,都还要回到原始标注上去。之前有人直接把XML删了,后来要出DOTA格式提交结果,只能对照着txt反推XML,折腾得够呛。最后再分享一个小技巧:转换完别急着删脚本,把脚本里的核心函数单独抽出来,留着下次复用。我这次就是直接把之前的转换函数拿过来改了个classes路径,五分钟搞定新数据,批处理跑完顺手可视化抽检一遍,整个过程稳稳当当。工具链这种东西,越沉淀越好用。