rolabelimg旋转框XML转YOLO可用txt:完整转换脚本与校验方案
上周帮朋友处理一批标注数据他花了整整三天用 rolabelimg 把一圈船体目标标完XML 文件躺在文件夹里整整齐齐。结果一到训练阶段检测框架导入标签时直接报错——它只认 txt而且是非常死板的那种格式。这种情况我见过太多次了。rolabelimg 保存的是 Pascal VOC 风格的 XML里面用robndbox记录旋转矩形而主流 YOLO 系训练管线要求的是 txt每行一个类别加归一化坐标。手工转几百张图能转死人直接拿旋转框的宽高填进 YOLO 里细长目标又会偏得离谱。这篇文章把我在项目里反复用的一套转换方案完整写出来从 XML 字段含义、坐标归一化的数学原理到能直接跑的 Python 脚本再到转完怎么可视化校验一次性讲透。不管你是刚接触旋转框标注的新手还是已经被 XML 转 txt 折磨过的老手照着这套思路走一遍基本不会再在数据格式上翻车。1. 为什么要折腾这个转换——先看清两套体系怎么对不上1.1 rolabelimg 保存的到底是什么rolabelimg 本质上是给普通目标检测标注工具加了旋转框支持。标注时你拖出来的不是正矩形而是带角度的框它保存时也不会用常规的bndbox左上角右下角两个点而是用robndbox记录中心点、长宽、角度。一份典型的 XML 长这样annotation filenameIMG_20240105_001.jpg/filename size width1920/width height1080/height depth3/depth /size object typerobndbox/type nameship/name robndbox cx960.5/cx cy540.2/cy w320.0/w h120.0/h angle35.0/angle /robndbox /object /annotation这里cx、cy是旋转矩形中心在图像像素坐标系里的横纵坐标w是旋转矩形长边长度h是短边长度angle是长边相对水平方向的旋转角度。注意它没有直接保存四个顶点所有顶点都得靠这五个数算出来。很多刚开始接触的人会以为 XML 里字段齐全直接读filename匹配图片就能转。实际一跑就会发现各种边缘情况有的 XML 里size和图片实际尺寸对不上有的angle单位其实是你没想到的弧度制还有的标注文件里混着大量空object节点。这些坑后面都会讲到。1.2 YOLO 体系的 txt 格式很“死板”YOLO 系列框架的标注格式几乎是固定的一个图片对应一个同名 txttxt 里的每一行代表一个目标格式是class_id x_center y_center width height四个坐标全部归一化到 0~1也就是除以图片的宽高。类别不是用名字而是用整数索引索引对应训练配置文件里的 classes 列表顺序比如 classes 是[ship, plane, container]那么 ship 就是 0plane 就是 1。这种设计有两个明显特点。第一它假设框是轴对齐的没有角度字段。第二它要求 txt 和图片同名同目录或者通过路径配置严格对应。所以当你手里全是 XML 时第一步要做的其实是“格式翻译”不是简单换后缀名而是把旋转框信息变成 YOLO 能理解的轴对齐框。这也是标题里“转为 yolo 可用 txt”这个说法的由来——可用意味着格式、坐标、类别索引、文件命名全部对齐缺一样训练时都会报错或者静默学错。1.3 旋转框信息去哪了可用和精确之间的取舍这里必须说清楚一个关键决策如果你训练的是普通 YOLO 目标检测模型它根本不支持旋转框角度信息只能丢掉把旋转框退化成水平外接矩形。但如果你后面要训练旋转框检测模型OBB角度就必须保留输出的 txt 格式也要换成带角度的变体。两种模式我在后面都会给出可运行的脚本参数。普通检测用hbb模式horizontal bounding box旋转检测用obb模式oriented bounding box。绝大多数人一开始只是想要一个“能跑起来”的检测模型所以我下面的核心推导和默认配置都围绕hbb模式展开obb模式作为扩展方案放在最后。2. XML 里的字段怎么读才对——几个容易踩的细节2.1 robndbox 的五个数字含义和边界cx、cy是像素坐标取值范围在图片宽高范围内。w是旋转矩形长边h是短边注意顺序不能搞反因为后面算外接矩形时w和h的位置会影响结果。angle是长边与图像 x 轴正方向的夹角单位我见过的是度degree范围通常在[-90, 90]之间。用 Python 标准库解析非常直接import xml.etree.ElementTree as ET def parse_annotation(xml_path): tree ET.parse(xml_path) root tree.getroot() objects [] for obj in root.findall(.//object): name obj.findtext(name).strip() robndbox obj.find(robndbox) 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)) objects.append({ name: name, cx: cx, cy: cy, w: w, h: h, angle: angle }) return objects注意.findall(.//object)这个写法能容忍 XML 里的层级差异不管object是否直接挂在annotation下面都能找到。我见过部分标注工具会在中间多套一层source或者object_list用绝对路径查找容易扑空。2.2 angle 的方向约定逆时针还是顺时针绝大多数文档不会告诉你不同版本的标注工具angle的方向约定可能不一样。rolabelimg 在 OpenCV 的坐标体系里y 轴向下长边相对 x 轴正方向逆时针旋转时angle通常为正但也有版本保存的是顺时针为正甚至有的版本会输出负角。怎么验证找一张图标一个明显斜着的目标比如一个 45 度的杆子导出 XML 后看看angle的值。如果杆子右边高左边低而你得到的 angle 是正数说明是逆时针为正反之就是顺时针为正。这个方向对hbb模式没有任何影响因为外接矩形的宽高计算只用到角度绝对值的正余弦方向反了结果一样。但如果你输出obb模式角度方向不对检测头训练出来会全乱。所以obb场景下必须先确认方向约定再决定要不要做angle -angle的修正。2.3 图像尺寸转换时最容易被坑的地方YOLO 的归一化一定要除以图片真实宽高。很多转换脚本图省事直接读 XML 里的size字段。问题是 rolabelimg 保存在 XML 里的size是标注那一刻图像文件的尺寸如果标注之后图片被压缩、裁剪、替换过尺寸就失真了。我实际遇到过一批无人机拍摄的图片外包处理组为了传输方便全部压到原分辨率的一半但 XML 是在压缩前标注的。直接用 XML 的尺寸做归一化出来的框全部偏移训练出来模型在真实场景下的检测框位置普遍偏左上查了半天才发现是标签坐标除错了分母。所以最稳妥的做法是转换时优先读取图片本身的宽高。图片找不到时再用 XML 里的size兜底。读取图片尺寸用 PIL 比较轻量from PIL import Image def get_image_size(image_path): with Image.open(image_path) as img: return img.size[0], img.size[1] # (width, height)这里注意Image.size返回的是(width, height)别和 OpenCV 的shape[:2]height, width搞混。顺序搞反归一化坐标全错属于那种不仔细看根本发现不了的隐蔽 bug。3. 转换的逻辑推导和完整脚本——从数学到代码3.1 理解归一化和“投影”问题很多教程直接把旋转框的w除以图片宽、h除以图片高就完事了。这是错的。YOLO 的水平框描述的是目标在图像中的水平和垂直投影范围而旋转矩形的w、h描述的是物体自身的长和宽。打个比方一根竖着的筷子长 30 厘米垂直投影确实 30 厘米把它斜着放 45 度桌面上的水平投影只剩大约 21 厘米但你手里的筷子还是 30 厘米。标注框应该贴合的是“投影”而不是物体自身尺寸。所以旋转框转水平外接矩形时真正的宽度和高度要算一下。设旋转角为 θ弧度制外接矩形的宽度W和高度H计算公式W w * |cos(θ)| h * |sin(θ)| H w * |sin(θ)| h * |cos(θ)|中心点坐标不变还是(cx, cy)。用前面那个例子验证w320h120angle35 度。先把 35 度转成弧度算出cos(35°) ≈ 0.819sin(35°) ≈ 0.574带入公式W 320 * 0.819 120 * 0.574 262.1 68.9 331.0 H 320 * 0.574 120 * 0.819 183.7 98.3 282.0可以看到短边 120 的矩形因为旋转在竖直方向的投影被拉长到了 282。如果直接用 320×120 去归一化标注框完全包不住目标模型学到的全是错位框。3.2 支持两种输出格式的转换脚本下面给出一个完整脚本是我项目里一直在用的版本参数化设计支持hbb和obb两种输出模式import argparse import math import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image def get_image_size(xml_path, img_dir): stem xml_path.stem for ext in [.jpg, .jpeg, .png, .bmp, .tif]: candidate Path(img_dir) / (stem ext) if candidate.exists(): with Image.open(candidate) as img: return img.size[0], img.size[1] tree ET.parse(xml_path) size tree.find(.//size) if size is not None: w int(size.findtext(width)) h int(size.findtext(height)) return w, h raise FileNotFoundError(fcannot find image for {xml_path.name}) def parse_objects(xml_path): tree ET.parse(xml_path) root tree.getroot() objs [] for obj in root.findall(.//object): name obj.findtext(name).strip() rb obj.find(robndbox) objs.append({ name: name, cx: float(rb.findtext(cx)), cy: float(rb.findtext(cy)), w: float(rb.findtext(w)), h: float(rb.findtext(h)), angle: float(rb.findtext(angle)), }) return objs def load_classes(classes_path): with open(classes_path, r, encodingutf-8) as f: names [line.strip() for line in f if line.strip()] return {name: idx for idx, name in enumerate(names)} def convert(xml_path, img_dir, out_dir, class_map, modehbb): img_w, img_h get_image_size(xml_path, img_dir) objs parse_objects(xml_path) lines [] for obj in objs: if obj[name] not in class_map: raise ValueError(funknown class {obj[name]} in {xml_path.name}) cls_id class_map[obj[name]] cx, cy, w, h, angle obj[cx], obj[cy], obj[w], obj[h], obj[angle] if mode hbb: theta math.radians(angle) abs_c abs(math.cos(theta)) abs_s abs(math.sin(theta)) W w * abs_c h * abs_s H w * abs_s h * abs_c nw, nh W / img_w, H / img_h xc, yc cx / img_w, cy / img_h xc max(0.0, min(1.0, xc)) yc max(0.0, min(1.0, yc)) nw max(0.0, min(1.0, nw)) nh max(0.0, min(1.0, nh)) lines.append(f{cls_id} {xc:.6f} {yc:.6f} {nw:.6f} {nh:.6f}) elif mode obb: xc, yc cx / img_w, cy / img_h nw, nh w / img_w, h / img_h lines.append(f{cls_id} {xc:.6f} {yc:.6f} {nw:.6f} {nh:.6f} {angle}) out_txt Path(out_dir) / (xml_path.stem .txt) if lines: out_txt.write_text(\n.join(lines) \n, encodingutf-8) else: out_txt.write_text(, encodingutf-8) def main(): parser argparse.ArgumentParser(descriptionrolabelimg XML to YOLO txt) parser.add_argument(--xml_dir, typestr, requiredTrue, helpXML folder) parser.add_argument(--img_dir, typestr, requiredTrue, helpimage folder) parser.add_argument(--out_dir, typestr, requiredTrue, helpoutput folder) parser.add_argument(--classes, typestr, requiredTrue, helpclasses.txt path) parser.add_argument(--mode, typestr, choices[hbb, obb], defaulthbb) args parser.parse_args() class_map load_classes(args.classes) out_dir Path(args.out_dir) out_dir.mkdir(parentsTrue, exist_okTrue) xml_files sorted(Path(args.xml_dir).glob(*.xml)) for xml_file in xml_files: try: convert(xml_file, args.img_dir, out_dir, class_map, args.mode) print(fconverted: {xml_file.name}) except Exception as e: print(ffailed: {xml_file.name} - {e}) if __name__ __main__: main()使用方式python xml2yolo.py \ --xml_dir ./annotations \ --img_dir ./images \ --out_dir ./labels \ --classes classes.txt \ --mode hbb3.3 脚本里几个设计细节说明先从图片读尺寸再回退 XML这个顺序不能反。上面代码里get_image_size就是先遍历常见图片后缀在当前目录找同名文件找不到才解析 XML 的size。虽然多了一点 IO但对几百上千张图来说开销完全可以忽略。类别映射从外部文件加载好处是换数据集时不用改脚本。classes.txt 一行一个类别顺序就是索引顺序。比如ship plane container那么 ship 就是 0plane 就是 1。这个顺序必须和训练配置里的 class names 完全一致否则模型训练时类别错位是灾难性的。空 XML 生成空 txt。有些 XML 里没有任何object也就是没有标注目标这种情况我选择生成一个空 txt 而不是跳过因为 YOLO 训练流程里图片和标签必须一一对应缺一个标签文件可能导致图片被当成无标签样本跳过反而影响训练配置的检查。坐标做了 clamp。目标贴边时外接矩形可能超出图像边界归一化后数值可能大于 1 或小于 0。大部分检测框架内部会自己处理但为了防止极端情况我统一把坐标裁剪到 0~1 之间省去后续排查的麻烦。4. 转完不是结束——坐标校验才能开训4.1 用 OpenCV 快速画框看效果转换完第一件事不是急着训练而是抽样可视化。我写过一个最小校验脚本读一张图和它对应的 txt在原图上把框画出来import cv2 import numpy as np def draw_boxes(image_path, label_path, classes_path, modehbb): img cv2.imread(image_path) h, w img.shape[:2] with open(classes_path, r, encodingutf-8) as f: class_names [line.strip() for line in f if line.strip()] with open(label_path, r, encodingutf-8) as f: for line in f: parts line.split() cls_id int(parts[0]) if mode hbb: xc, yc, nw, nh map(float, parts[1:5]) x1 int((xc - nw / 2) * w) y1 int((yc - nh / 2) * h) x2 int((xc nw / 2) * w) y2 int((yc nh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, class_names[cls_id], (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) elif mode obb: xc, yc, nw, nh, angle map(float, parts[1:]) cx, cy xc * w, yc * h bw, bh nw * w, nh * h box cv2.boxPoints(((cx, cy), (bw, bh), angle)) box np.int0(box) cv2.polylines(img, [box], True, (0, 0, 255), 2) cv2.imshow(check, img) cv2.waitKey(0) cv2.destroyAllWindows()跑起来后逐张翻重点看三类问题框是否贴合目标边缘、细长目标是否会出现明显的“斜框包不进去”现象、类别名是否对得上目标内容。4.2 怎么判断框“对”了肉眼看完我还会做一道数值抽查。随便挑一张图在 rolabelimg 里看原始旋转框的cx、cy、w、h、angle然后手工算一遍外接矩形和脚本输出的 txt 对比。拿前面那个示例原图 1920×1080目标 cx960.5cy540.2w320h120angle35。手工算外接矩形 W≈331.0H≈282.0归一化后 xc960.5/1920≈0.5003yc≈0.5002nw≈0.1724nh≈0.2611。如果 txt 里这几个数差得远那就要回查是不是角度单位出错或者 w、h 顺序写反了。还有一个我自己常犯的错误角度明明是度脚本里却忘了转弧度直接用cos(35)得到的结果完全是另一回事。很多转换脚本跑出来的框“差一点”但不至于离谱多半是这一类单位问题。4.3 批量数据里的暗坑可视化抽查没问题后还要对整个数据集做一遍全量检查。最常用的方式是把所有 txt 里的坐标做一次统计最大值、最小值、是否存在负值、是否存在大于 1 的值。如果出现大量坐标刚好等于 1说明目标框可能被错误裁剪了。另一个暗坑是图片被重压缩后文件名没变但尺寸变了。我之前那个项目的教训就是XML 从服务器下载时带着标注时的尺寸图片却被前端的缩略图组件重新处理过导致一部分图片分辨率变成了原来的 1/4转换脚本按实际读图尺寸归一化后没问题但 rolabelimg 里重新打开时对不上。所以批量转换前最好先对所有图片尺寸做一次分布统计确认没有异常分辨率混入。还有一类问题是类别名大小写不一致。rolabelimg 里标注了Shipclasses.txt 里写的是ship脚本直接报错而不是静默跳过这个我很早就设计成宁可报错也不要漏标。漏标的图片训练时不会报错但模型永远学不到那个目标排查难度比报错大得多。5. 把这个转换工具接到真实工作流里5.1 管理类别映射的姿势类别映射不是一次性的事。我建议在项目根目录维护一份 classes.txt同时在训练脚本里用同一个文件加载 class names避免在配置文件里再写一遍列表导致不一致。转换脚本、训练脚本、推理脚本都读同一份 classes.txt这样增删类别时只改一个文件。类别顺序也要想好再定。一旦开始训练再去调整 classes.txt 的顺序意味着所有 txt 的类别索引都要重新转换一遍。如果数据集还没定型最好先把所有可能出现的类别一次性列全空类别可以先放在后面但不要中途插入到前面。5.2 从 XML 到训练一个完整的流水线建议整套流程我现在的习惯是这样的标注完成后先跑一遍 XML 解析统计确认类别列表和文件数量。执行转换脚本生成 txt统一放到 labels 目录。运行可视化校验脚本抽样 20 到 30 张重点看长宽比大的目标。看 JSON 统计报告检查坐标范围、空标签比例、每类别目标数量。划分训练集和验证集时保证图片和 txt 一起切分不能只切图片忘了标签。确认没问题后再开始写训练配置。这个流程里最容易忽略的是第 5 步。切分脚本如果只遍历图片文件收集列表标签文件就会漏掉训练时报“图片找不到标签”的错误看起来很头疼其实只是切分逻辑不完整。5.3 如果以后要训旋转框检测普通 YOLO 检测满足不了需求时你会需要旋转框检测。这时候obb模式输出的class cx cy w h angle可以直接作为带旋转角度的标签喂给支持 OBB 的检测框架但要注意不同框架对角度单位的要求不一样有的要弧度有的要度有的角度范围是[-90, 0)有的是[0, 180)这个是训练前必须确认的硬前提。如果你的检测框架吃的是 DOTA 四顶点格式那就得把cx、cy、w、h、angle换算成四个顶点。换算公式是标准的旋转坐标变换设中心为(cx, cy)长边方向角为 θ四个顶点相对中心的偏移分别是(-w/2, -h/2)、(w/2, -h/2)、(w/2, h/2)、(-w/2, h/2)旋转后再平移到中心坐标。对应代码片段import math def rotated_box_to_corners(cx, cy, w, h, angle_deg): theta math.radians(angle_deg) cos_t math.cos(theta) sin_t math.sin(theta) offsets [ (-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2) ] corners [] for dx, dy in offsets: rx cx dx * cos_t - dy * sin_t ry cy dx * sin_t dy * cos_t corners.append((round(rx, 2), round(ry, 2))) return corners这段代码可以直接扩展进转换脚本输出格式变成class x1 y1 x2 y2 x3 y3 x4 y4。注意顶点顺序要固定DOTA 格式一般要求按顺时针或逆时针统一排列乱了顺序模型训练就会出问题。我在实际项目里的体会是数据格式转换这件事看起来是脚本问题本质上是数据治理问题。格式没理顺后面的训练、调参、部署全都要跟着遭殃。与其每次接到新数据集都临时写一个转换脚本不如趁早把这套流程固化下来类别映射、尺寸回退、可视化校验、统计报告全部自动化。第一次搭这套流程可能多花半天但后面每次处理新数据都能省下不止半天而且中间踩过的那些坑不会再踩第二遍。最后分享一个小技巧转换脚本不要只在命令行跑完就扔最好把日志留全哪个 XML 转换失败、为什么失败、图片文件缺失还是类别不在表里都打印出来。批量处理几百个文件时靠肉眼逐个看日志不现实但把错误信息统一收集到 log 文件里排序去重后扫一眼数据质量问题基本一眼就能定位到根源比训练跑一半才发现标签错了再来回头查数据高效得多。