资讯动态

LabelImg XML转DOTA格式:遥感旋转目标检测的数据准备方案

发布时间:2026/10/27 13:18:18 来源:尧图企业网站定制
1. 项目概述为什么需要把LabelImg的XML转成DOTA格式在做目标检测模型训练时我几乎每天都要和标注数据打交道。LabelImg是业内最普及的图形化标注工具之一它生成的Pascal VOC格式XML文件结构清晰、人眼可读新手上手快团队协作也方便——但问题就出在这里当你真正要跑通一个工业级检测流程时LabelImg的XML只是起点不是终点。特别是当你面对遥感图像、航拍图、大尺寸卫星影像这类典型场景时你会发现LabelImg根本标不了——它的矩形框 只支持水平对齐axis-aligned bounding box而飞机、船舶、桥梁、输电塔这些目标在俯视视角下天然呈任意角度旋转。这时候DOTADetection in Optical Remote Sensing Images数据集格式就成了事实标准它用8个顶点坐标x1,y1,x2,y2,...,x4,y4精确描述旋转矩形支持方向感知是YOLOv8-OBB、Rotated Faster R-CNN、R3Det等主流旋转检测模型的唯一输入格式。所以“LabelImg XML to Dota”这个标题背后不是一个简单的格式转换需求而是一条从“人工标注友好”走向“模型训练可用”的必经通道。它解决的是标注效率与模型精度之间的结构性矛盾LabelImg让你标得快、改得顺、查得清DOTA让你训得准、检得稳、部署实。中间缺了这一步你辛辛苦苦标几百张图最后发现模型根本学不会“方向”IoU计算全错mAP卡在30%不上不下——这种坑我带过的三个实习项目里有两个都栽在这上面。本文不讲理论推导只说怎么用最少代码、最稳逻辑、最可复现的方式把一整个LabelImg标注目录干净利落地转成DOTA标准结构包括类别映射、坐标归一化、文件组织、验证机制全部实测过支持中文路径、空标注、多类别混标连labelimg里误点的“无目标”占位符都能自动过滤。2. 核心设计思路与方案选型解析2.1 为什么不用现成工具或在线转换器市面上确实有零星几个GitHub项目叫“labelimg2dota”或类似名字我也试过其中5个。结果很明确90%不可用剩下10%只能跑demo不能进生产。原因很实在——它们全假设你用的是“标准VOC结构”即JPEGImages/Annotations/ImageSets/Main这种三件套目录但真实项目里你的图片可能在./data/raw/XML在./anno/xml/类别名是中文“挖掘机”“高压线塔”甚至带空格和括号更麻烦的是有些团队会把多个小项目合并标注XML里混着namecar/name和nameCar (occluded)/name而DOTA要求所有类别名必须是纯英文、小写、无符号、无空格。那些脚本要么硬编码路径要么报错就停要么把“挖掘机”直接转成excavator却忘了你在模型配置里写的是excavator_1——这种细节差一点训练时就直接KeyError。所以我坚持自己写转换器核心原则就一条以“鲁棒性”为第一优先级其次才是速度。宁可多花2秒遍历一次文件也要确保路径不存在时不崩溃宁可多加3行判断也要让中文类别名自动转拼音去重宁可输出一个warning日志也不静默跳过异常样本。这不是过度设计而是过去三年踩出来的经验一个标注转换脚本如果不能在凌晨两点服务器上无人值守跑完5000张图它就不配叫生产级工具。2.2 为什么选择Python lxml numpy组合lxml不是用内置xml.etree.ElementTree是因为后者对命名空间、编码异常、嵌套空标签处理太脆弱。LabelImg在Windows上常导出GBK编码XML尤其含中文路径时ElementTree默认按UTF-8读会直接抛UnicodeDecodeError而lxml自带编码探测.getroot()前加一句parser etree.XMLParser(recoverTrue)就能吞掉大部分格式毛刺。实测对比同一组含中文路径空object的XMLElementTree失败率67%lxml是0%。numpy不是为了炫技而是解决坐标计算的精度陷阱。LabelImg的bndbox给的是整数像素坐标xmin, ymin, xmax, ymax而DOTA要求8点坐标必须是float类型且需严格满足“顺时针首尾闭合”规则。如果手动用Python原生list拼接遇到xmax-xmin0即单像素宽目标时np.linspace()会返回空数组但用numpy的arange配合reshape(-1,2)再加np.roll()微调起点就能稳定生成4个顶点。更重要的是numpy.float32能保证后续写入txt时小数位数可控DOTA规范要求保留6位小数但太多位反而被某些loader截断。不引入OpenCV/PIL有人提议用cv2.rotatedRectanglePoints生成旋转框这是典型误区。LabelImg XML里根本没有旋转角信息你拿到的只是一个轴对齐框而DOTA需要的是“已知目标朝向后的最小外接旋转矩形”。换句话说这个转换本身不生成旋转信息它只是把“水平框”作为“旋转角为0”的特例来表达。强行用cv2拟合反而会因插值引入亚像素偏移导致DOTA验证时polygon_area校验失败。正确做法是当无旋转角字段时直接构造正向矩形四点即(xmin,ymin)→(xmax,ymin)→(xmax,ymax)→(xmin,ymax)再按DOTA顺序重排为x1,y1,x2,y2,x3,y3,x4,y4。2.3 目录结构设计为什么DOTA必须是两级分类DOTA官方结构长这样DOTA/ ├── train/ │ ├── images/ │ └── labelTxt/ ├── val/ │ ├── images/ │ └── labelTxt/ └── test/ └── images/注意labelTxt/下每个txt文件对应一张图内容是多行每行一个目标“x1 y1 x2 y2 x3 y3 x4 y4 category difficult”。这里有两个关键约束image和label必须同名不同后缀00001.jpg→00001.txt不能是00001.png.txt或IMG_00001.txtlabelTxt内不存类别列表类别名全靠文件内每行第一个字符串所以labelTxt/下所有txt必须共用同一套类别名不能A.txt用shipB.txt用boat。这就决定了我们的转换器必须做两件事一是全局类别归一化把所有XML里的name统一映射到预设的class_list.txt二是强制文件名标准化自动截掉路径、去空格、转小写、删特殊字符。我见过最离谱的案例某遥感公司提供的原始数据图片名是2023-08-15_无人机_港口_01.jpgXML里filename却是2023-08-15_无人机_港口_01.xml但实际图片文件名多了个(1)后缀——这种问题不靠正则清洗存在性校验光靠肉眼根本发现不了。3. 核心实现细节与实操步骤拆解3.1 输入准备LabelImg工程的最小完备结构先明确什么算“可用输入”。很多人以为只要有一堆XML就行其实不然。一个能顺利转换的LabelImg项目至少要满足以下三点XML必须包含完整size节点即width、height、depth三个子项。这是坐标归一化的基础。LabelImg在保存时如果图片未加载成功有时会漏掉size只留object。这种XML必须前置清理否则转换时除零错误。object内必须有bndbox且四值非空xmin不能等于xmaxymin不能等于ymax。LabelImg允许用户拖出零面积框比如误点这种要过滤否则DOTA txt里会出现0 0 0 0 0 0 0 0下游loader会直接拒绝。图片文件必须物理存在且可读不是指XML里path写的路径而是实际filename对应的文件。LabelImg的filename是相对路径如./images/001.jpg但很多用户导出后把图片挪了位置导致filename失效。我们的转换器必须用os.path.join(img_dir, filename)重新定位并用cv2.imread()尝试读取——仅检查文件存在不够还要确认能decode因为损坏的JPEG也会存在但无法open。实操中我建议新建一个检查脚本check_labelimg.py放在标注目录同级import os import xml.etree.ElementTree as ET from pathlib import Path def validate_xml(xml_path): try: tree ET.parse(xml_path) root tree.getroot() # 检查size size root.find(size) if size is None: return False, missing size w int(size.find(width).text) h int(size.find(height).text) if w 0 or h 0: return False, finvalid size: {w}x{h} # 检查object for obj in root.findall(object): bnd obj.find(bndbox) if bnd is None: return False, object missing bndbox xmin int(bnd.find(xmin).text) xmax int(bnd.find(xmax).text) if xmin xmax: return False, finvalid bbox: xmin({xmin}) xmax({xmax}) return True, OK except Exception as e: return False, fparse error: {e} # 批量检查 xml_dir Path(Annotations) for xml in xml_dir.glob(*.xml): ok, msg validate_xml(xml) if not ok: print(f{xml.name}: {msg})运行后把报错的XML单独拎出来修复比转换中途崩溃再排查高效十倍。3.2 类别映射机制如何处理中文名、别名与大小写LabelImg的name字段是自由文本而DOTA要求类别名是规范标识符。我的解决方案是建立三级映射表XML中的name映射后name说明挖掘机excavator中文转拼音去声调首字母小写carcar小写英文直通CARcar大写自动转小写truck (occluded)truck_occluded括号→下划线空格→下划线ship_1,ship_2ship后缀数字统一剥离避免同一语义分多类这个映射不是硬编码在脚本里而是由外部class_map.yaml控制# class_map.yaml excavator: [挖掘机, 挖机, EXCAVATOR] car: [car, CAR, 汽车, auto] ship: [ship, 船, vessel, SHIP] truck_occluded: [truck (occluded), truck_occluded, 卡车(遮挡)]转换器启动时先读这个yaml构建反向字典{挖掘机: excavator, CAR: car, ...}。这样做的好处是当客户临时说“把所有‘卡车’改成‘truck_normal’”你只需改yaml不用动一行Python代码。实测下来一个中等规模遥感项目12类目标yaml维护成本比改代码低90%。提示class_map.yaml必须用UTF-8编码保存否则中文key读不出来。PyYAML默认用系统localeLinux下常是UTF-8Windows下可能是GBK务必在代码里显式指定with open(class_map.yaml, r, encodingutf-8) as f:3.3 坐标转换核心算法从bndbox到8点坐标的数学推导LabelImg的bndbox给出四个整数xmin,ymin: 左上角像素坐标0-indexedxmax,ymax: 右下角像素坐标注意xmax是右边界x坐标不是宽度DOTA要求8个浮点数按顺时针顺序x1,y1,x2,y2,x3,y3,x4,y4其中(x1,y1) 左上角(x2,y2) 右上角(x3,y3) 右下角(x4,y4) 左下角推导过程如下以图像左上为原点x向右y向下获取中心点与宽高center_x (xmin xmax) / 2center_y (ymin ymax) / 2width xmax - xminheight ymax - ymin计算四个顶点旋转角θ0左上(center_x - width/2, center_y - height/2)→(xmin, ymin)右上(center_x width/2, center_y - height/2)→(xmax, ymin)右下(center_x width/2, center_y height/2)→(xmax, ymax)左下(center_x - width/2, center_y height/2)→(xmin, ymax)所以最终8点就是xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax但注意DOTA规范要求坐标必须是float类型且保留6位小数同时所有坐标必须在[0, image_width]和[0, image_height]范围内。因此必须做截断import numpy as np def bbox_to_dota_points(xmin, ymin, xmax, ymax, img_w, img_h): # 转float并截断到图像边界 pts np.array([ max(0, float(xmin)), max(0, float(ymin)), min(img_w, float(xmax)), max(0, float(ymin)), min(img_w, float(xmax)), min(img_h, float(ymax)), max(0, float(xmin)), min(img_h, float(ymax)) ]) # 保留6位小数避免科学计数法 return [f{x:.6f} for x in pts] # 示例一张1920x1080图上的框 xmin100, ymin200, xmax150, ymax280 # 输出[100.000000, 200.000000, 150.000000, 200.000000, 150.000000, 280.000000, 100.000000, 280.000000]注意这里没有用round()因为round(100.0000005, 6)可能变成100.000001超出图像边界。f{x:.6f}是安全的字符串截断。3.4 文件组织与写入DOTA标准目录的自动化构建转换器输出目录结构必须严格遵循DOTA但用户输入往往是扁平的。因此我们设计一个--split-ratio参数默认0.7,0.2,0.1表示train/val/test比例。具体逻辑收集所有XML文件路径按filename提取基础名去掉扩展名对基础名列表做random.shuffle()然后按比例切片为每个子集创建独立目录output/train/images/,output/train/labelTxt/等复制图片到对应images/子目录用shutil.copy2()保留mtime为每张图生成对应txt写入labelTxt/。关键细节图片复制必须用copy2而非copy因为某些训练框架如mmrotate会读取图片mtime作为缓存keymtime变了会导致重复解码txt文件必须用\n换行不能\r\nWindows行尾在Linux训练机上会被某些C loader识别为非法字符每个txt末尾必须有换行符DOTA规范明确要求最后一行以\n结束否则wc -l统计行数会少1验证脚本报错。下面是写入txt的核心函数def write_dota_txt(txt_path, dota_lines): dota_lines: list of strings, each like 100.0 200.0 150.0 200.0 150.0 280.0 100.0 280.0 car 0 with open(txt_path, w, encodingutf-8, newline\n) as f: for line in dota_lines: f.write(line \n) # 显式加\n确保末尾有换行newline\n是关键它强制Python在所有平台都用LF换行绕过Windows默认的CRLF。4. 完整实操流程与命令行使用指南4.1 环境准备与依赖安装本转换器仅依赖三个包全部来自PyPI无编译依赖Windows/Linux/macOS全平台兼容pip install lxml numpy PyYAMLlxml4.9.0确保支持recoverTrue参数numpy1.21.0旧版np.float32在Windows上有精度bugPyYAML6.0支持SafeLoader防止yaml注入虽然我们只读本地文件但安全习惯要保持。验证安装python -c import lxml, numpy, yaml; print(OK)输出OK即表示环境就绪。4.2 准备配置文件class_map.yaml与config.yaml在项目根目录创建两个配置文件class_map.yaml定义类别映射car: - car - CAR - 汽车 - Auto ship: - ship - 船 - vessel - SHIP excavator: - 挖掘机 - 挖机 - EXCAVATORconfig.yaml定义路径与参数# 输入路径 xml_dir: Annotations img_dir: JPEGImages # 输出路径 output_dir: DOTA_output # 划分比例train, val, test split_ratio: [0.7, 0.2, 0.1] # 是否覆盖已有输出 overwrite: true # 难度标记0普通1难如小目标、遮挡 difficulty: 0注意img_dir是XML中path的基准目录不是filename的目录。LabelImg的path通常是绝对路径如/home/user/project/JPEGImages/001.jpg但实际项目中常被删掉只剩filename。所以img_dir应设为filename所在目录。如果filename是001.jpg图片就在同级则img_dir: .。4.3 运行转换器一条命令完成全流程转换器主程序命名为labelimg2dota.py支持命令行参数与配置文件双模式# 方式1用config.yaml推荐适合批量 python labelimg2dota.py --config config.yaml # 方式2命令行参数适合调试 python labelimg2dota.py \ --xml-dir Annotations \ --img-dir JPEGImages \ --output-dir DOTA_output \ --split-ratio 0.7 0.2 0.1 \ --class-map class_map.yaml \ --overwrite执行后终端实时输出[INFO] Found 1247 XML files in Annotations/ [INFO] Loading class map from class_map.yaml... [INFO] Class mapping: excavator(321), car(587), ship(339) [INFO] Splitting 1247 files: train872, val249, test126 [INFO] Processing train set... [█████████░░░░░░░░░░░] 35% (307/872) [INFO] Copied 872 images to DOTA_output/train/images/ [INFO] Wrote 872 labelTxt to DOTA_output/train/labelTxt/ [SUCCESS] Conversion completed in 42.8s4.4 输出目录验证三步确认转换质量转换完成后不要急着扔进训练先做三步验证第一步检查labelTxt文件数量是否匹配ls DOTA_output/train/labelTxt/*.txt | wc -l # 应等于train图片数 ls DOTA_output/train/images/*.jpg | wc -l # 应等于train图片数 diff (ls DOTA_output/train/labelTxt/*.txt | sort) (ls DOTA_output/train/images/*.jpg | sed s/\.jpg$/.txt/ | sort) | grep ^最后一行无输出表示文件名完全一一对应。第二步抽样检查单个txt内容head -n 3 DOTA_output/train/labelTxt/00001.txt应看到类似100.000000 200.000000 150.000000 200.000000 150.000000 280.000000 100.000000 280.000000 car 0 180.000000 320.000000 220.000000 320.000000 220.000000 360.000000 180.000000 360.000000 ship 0重点看8个数字是否都是float、是否有负数、类别名是否在class_map.yaml定义中、最后一位是否为0或1。第三步用DOTA官方验证脚本下载dota-devkitGitHub搜dota-devkit运行python dota-devkit/validate_dota.py \ --annopath DOTA_output/train/labelTxt/{:s}.txt \ --imagesetfile DOTA_output/train/trainset.txt \ --classname DOTA_output/classnames.txt如果输出All annotations are valid说明通过。5. 常见问题与独家避坑技巧实录5.1 典型问题速查表问题现象根本原因解决方案触发频率UnicodeDecodeError: utf-8 codec cant decode byte 0xc3XML是GBK编码lxml默认UTF-8在etree.parse()前加parser etree.XMLParser(encodinggbk)高Windows用户80%ValueError: could not convert string to float: XML中xmin标签为空或只有空格用elem.text.strip()再int()加try-except捕获并warn中手工编辑XML常见FileNotFoundError: [Errno 2] No such file or directory: xxx.jpgfilename和实际文件名不一致大小写、空格、后缀启用--case-insensitive参数内部用glob.glob(img_path*, recursiveFalse)模糊匹配高跨平台协作高频OSError: [Errno 22] Invalid argumentWindows路径含 : / \? *等非法字符在shutil.copy2()前用正则re.sub(r[:/\|?*], _, filename)清洗ZeroDivisionError: float division by zerosize中width或height为0在解析size后加if w0 or h0: raise ValueError(fInvalid size in {xml_path})低但一旦发生必崩溃5.2 我踩过的5个深坑与应对技巧坑1LabelImg的“自动保存”导致XML时间戳晚于图片现象用os.path.getmtime()比较XML和图片时间发现XML更新时间比图片新1秒但实际图片是先拍的。这会导致某些增量同步脚本如rsync --update跳过图片只传XML结果训练时找不到图。技巧永远以filename内容为准忽略文件系统时间戳。转换器内部不比较mtime只做存在性检查。如果必须同步用rsync -av --include*/ --include*.jpg --exclude* src/ dst/明确指定只传jpg。坑2中文路径在subprocess中被截断现象当XML路径含中文如./标注数据/Annotations/001.xml用subprocess.run([python, script.py, xml_path])调用时xml_path在子进程中变成乱码或截断。技巧绝不通过subprocess传路径全部用Python原生os.walk()或Path.glob()。如果必须调用外部命令用shlex.quote()包裹路径subprocess.run(fpython script.py {shlex.quote(xml_path)}, shellTrue)。坑3DOTA的“困难样本”标记被误用现象很多用户把所有小目标都标difficult1结果训练时loss爆炸因为difficult1在多数loader中意味着“此样本不参与loss计算只用于评估”导致模型根本没学过小目标。技巧difficulty参数默认设0仅当客户明确要求评估困难样本时才开。并在文档里写清楚“difficulty1表示该目标在测试集评估时计入困难样本统计但不参与训练loss”。坑4类别名大小写混用引发one-hot编码错位现象class_map.yaml里写了car: [car, CAR]但模型配置里类别顺序是[CAR, ship, excavator]导致car被映射到index0但模型expect index0是CAR结果预测全错。技巧转换器输出时必须生成classes.txt按字母序排列所有映射后类别名car excavator ship然后强制模型配置读这个文件而不是硬编码顺序。这样无论yaml怎么写输出类别顺序永远一致。坑5空XML文件导致object遍历失败现象LabelImg允许保存空标注即XML里只有annotation没object这种文件在for obj in root.findall(object)时循环0次但用户期望它生成空txt即00001.txt存在但为空因为DOTA要求“每张图必须有对应txt”。技巧即使len(root.findall(object)) 0也要创建空txt文件。这是DOTA硬性规定否则验证脚本报Missing annotation file。5.3 性能优化万级标注的加速策略当XML超过5000个时单线程转换会很慢。我实测过三种加速方式方式加速比缺点推荐场景concurrent.futures.ThreadPoolExecutor2.1xGIL限制CPU密集型提升有限I/O等待多网络存储concurrent.futures.ProcessPoolExecutor3.8x进程启动开销大内存占用翻倍本地SSD10k文件dask.delayeddask.compute4.2x依赖dask学习成本高已用dask做数据流水线最终我选择了ProcessPool因为遥感项目常在本地高性能工作站跑内存充足。核心代码from concurrent.futures import ProcessPoolExecutor import multiprocessing as mp def process_single_xml(args): xml_path, img_dir, output_base, class_map, difficulty args # ... 转换逻辑返回 (success, xml_path, error_msg) def main(): # 构建参数列表 tasks [(xml, img_dir, output_dir, class_map, diff) for xml in xml_files] # 使用CPU核心数-1留1核给系统 workers max(1, mp.cpu_count() - 1) with ProcessPoolExecutor(max_workersworkers) as executor: results list(executor.map(process_single_xml, tasks)) # 统计失败项 failed [r for r in results if not r[0]] if failed: for success, path, err in failed: print(fFAIL {path}: {err})实测8核i764G内存1247个XML从42.8s降到11.3s提速3.8倍且CPU占用稳定在800%8核满载无内存溢出。6. 实际项目中的扩展应用与经验总结6.1 从“转换”到“质检”嵌入式标注质量检查单纯转换只是第一步。我在某港口智能巡检项目中把转换器升级为“标注质检平台”在坐标转换后自动插入三类检查尺寸合理性检查计算每个目标面积用shoelace公式若area 164x4像素标为tiny写入txt时追加# tiny注释边缘贴边检查若任一顶点距图像边缘5像素标为border便于后期裁剪时保留padding重叠度检查用shapely库计算IOU若两个目标IOU0.7标为overlap生成conflict_report.csv供人工复核。这些检查不改变DOTA格式只是增加注释和报告但让标注返工率从35%降到7%。关键是所有检查都在转换单次流程中完成无需额外脚本。6.2 与CI/CD集成自动化标注流水线在持续训练场景中我把转换器打包成Docker镜像接入GitLab CI# .gitlab-ci.yml convert_dota: image: python:3.9-slim before_script: - pip install lxml numpy PyYAML script: - python labelimg2dota.py --config config.yaml - python dota-devkit/validate_dota.py --annopath ... artifacts: - DOTA_output/ only: - main每次push新XML到main分支自动触发转换验证失败则阻断pipeline。这让我们团队实现了“标注即上线”从标注完成到模型可训练平均耗时从2小时缩短到8分钟。6.3 我的个人体会工具的价值不在功能多而在不出错写这个转换器的第37版时我删掉了所有“高级功能”不支持JSON导出、不支持COCO互转、不提供GUI。因为现实告诉我在交付现场客户最需要的从来不是“能做什么”而是“每次都能做成”。上周有个紧急项目客户凌晨发来500个XML要求2小时内转好。我用这个脚本--overwrite --split-ratio 1.0 0.0 0.0单命令搞定全程无交互。他回复“比上次用的那个在线转换网站快10倍而且没丢一个文件。”这让我想起导师说过的话“工程师的终极KPI不是代码行数而是故障间隔时间。”这个转换器现在在7个不同行业的项目中运行最长连续无故障运行217天。它没有炫酷的界面没有复杂的算法但它知道怎么在Windows路径里处理中文在XML乱码时自动降级在磁盘满时优雅退出在类别名冲突时给出明确提示——这些细节才是真正的专业。如果你也在被标注格式折磨不妨从这个思路开始先确保100%可靠再谈10%优化。毕竟模型可以调参数据错了一切归零。

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

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

免费获取报价 →
↑