1. 为什么非得把DOTA的txt标签转成XML?——从目标检测数据流倒推设计逻辑
刚接触遥感图像目标检测的朋友,常会困惑:DOTA官方明明提供了标准的txt格式标注文件,结构清晰、字段明确,为什么还要费劲转成XML?这不是多此一举吗?我最初在某高校遥感实验室参与一个模拟项目X时,也抱着同样想法。直到我们把DOTA数据集直接喂给一个基于Pascal VOC协议训练的Faster R-CNN模型,训练过程突然中断报错——KeyError: 'object'。排查两小时后才发现,模型的数据加载器硬编码了对XML中<object>节点的解析逻辑,而txt里压根没有这个概念。那一刻我才真正理解:数据格式从来不是技术细节,而是不同工具链之间沉默的契约。
DOTA数据集的txt格式,本质是为“旋转框”这一特殊几何形态量身定制的。它用8个坐标点(x1,y1,x2,y2,x3,y3,x4,y4)描述任意四边形,再加一个类别名和一个难度标识(如plane 1)。这种表达极其紧凑,人类可读性好,也方便做坐标变换。但问题在于,主流深度学习框架(PyTorch的torchvision、TensorFlow的tf.data)及其配套工具(LabelImg、CVAT、甚至很多自研标注平台)默认只认两种“通用语言”:Pascal VOC的XML和COCO的JSON。它们的设计哲学是“先定义对象容器,再填充属性”,XML里的<object>就是那个容器,里面塞<name>、<bndbox>或<robndbox>。你不提供这个容器,工具就找不到入口。
更现实的痛点来自团队协作。我们曾和某公司合作一个跨平台系统,对方算法组用的是MMDetection,标注组用的是国产标注软件,导出默认是XML;而我们手头只有DOTA原始txt。如果每次训练前都手动改代码去适配txt,不仅效率低,还极易引入索引错位——比如把第3个坐标点误当成y2,导致整个旋转框歪斜30度。后来我们统一约定:所有进入训练管道的数据,必须是VOC风格XML。这看似是格式转换,实则是建立了一条不可逾越的“数据准入红线”。
所以,这个任务的核心价值,远不止于“把A文件变成B文件”。它是一次数据协议对齐工程:把DOTA的领域专有格式,映射到工业界通用的语义框架里。而“分割”这个动作,恰恰是这条映射链的起点——你得先把一张大图(如1024×1024)切分成若干小图(如600×600),再把落在每个小图内的旋转框坐标,从全局坐标系转换到局部坐标系,最后才能写进对应的XML。漏掉分割,直接转XML,结果就是所有框都挤在左上角,模型学不到任何空间关系。这就像把整本《辞海》的页码直接抄到一张A4纸上,字都在,但完全无法检索。
提示:很多新手会跳过分割直接转格式,以为“反正都是坐标”。实测发现,未分割的DOTA XML在YOLOv5训练中mAP暴跌40%,原因正是大图中目标尺度差异过大,小目标(如车辆)被大目标(如机场跑道)的梯度淹没。分割不是可选项,是必经的预处理环节。
2. DOTA分割的底层逻辑:为什么不能用普通图像裁剪?——旋转框坐标的数学归一化
普通图像分割(如用OpenCV的cv2.resize或PIL.Image.crop)对遥感图像行不通,根本原因在于:它只处理像素,不处理几何语义。DOTA里的每一个目标,都不是矩形框,而是带方向的四边形。当你把一张包含飞机的1024×1024大图切成4块600×600重叠小图时,原图中一个横跨两块小图的跑道,其8个顶点坐标必须被精确拆解——哪些点落在第一块,哪些点落在第二块,落在边界上的点如何取舍?更关键的是,这些点的坐标值必须从“以原图左上角为原点”的全局坐标系,平移到“以小图左上角为原点”的局部坐标系。这个过程,叫坐标系平移与截断,它需要严格的数学推导,而非简单的像素复制。
我们以一个具体例子说明。假设原图中有一架飞机,其8个顶点坐标为:(200,150), (250,140), (245,190), (195,200)(单位:像素),类别为plane。现在要将原图按步长400、尺寸600进行滑动窗口分割。第一个窗口的左上角坐标是(0,0),右下角是(599,599)。显然,这4个点全在此窗口内,那么它们在新坐标系下的坐标就是原坐标减去(0,0),即保持不变。但第二个窗口左上角是(400,0),此时第一个点(200,150)的x坐标200小于400,它就不属于这个窗口——直接丢弃。而第四个点(195,200)同理被剔除。但注意:如果有一个点是(420,160),它在新坐标系下就变成(420-400,160-0)=(20,160)。这个减法操作,就是坐标系平移的核心。
难点在于“截断”——当一个旋转框跨越窗口边界时,它可能被切割成一个三角形或五边形,而DOTA要求每个标注必须是完整的四边形。因此,行业通行做法是只保留完全落入窗口内的目标,或采用“中心点判据”:计算旋转框的几何中心(8个点坐标的平均值),若中心点落在窗口内,则保留整个四边形,即使部分顶点超出。后者更常用,因为它能保证小目标不被漏检。例如,一个微小的船舶,其四边形可能只有两个顶点在窗口内,但中心点在内,我们就把它整个保留下来,并将所有8个坐标减去窗口左上角坐标。
这里有个易错点:坐标减法必须严格对应。我曾踩过一个坑——在Python中用numpy.array存储坐标,误把窗口左上角坐标写成(y,x)顺序(因为OpenCV习惯先y后x),而DOTA txt是(x,y)顺序。结果所有框都向左上方偏移,训练时模型疯狂预测虚影。后来我们强制规定:所有坐标变量名必须带后缀,如window_top_left_x、window_top_left_y,并在函数开头加断言assert x_coords.min() >= 0 and y_coords.min() >= 0,确保输入坐标已归一化。
实际分割代码中,我们用了一个双重循环遍历所有可能的窗口位置:
# 假设原图宽高为orig_w, orig_h,窗口尺寸为patch_size=600,步长为stride=400 for start_x in range(0, orig_w - patch_size + 1, stride): for start_y in range(0, orig_h - patch_size + 1, stride): end_x = start_x + patch_size end_y = start_y + patch_size # 此时窗口区域为 [start_x, end_x) × [start_y, end_y) # 遍历该窗口内所有目标,计算其中心点 for obj in all_objects_in_image: center_x = sum(obj.x_coords) / 8.0 center_y = sum(obj.y_coords) / 8.0 if start_x <= center_x < end_x and start_y <= center_y < end_y: # 保留此目标,并转换坐标 new_coords = [x - start_x for x in obj.x_coords] + [y - start_y for y in obj.y_coords] # 写入新txt或直接构造XML这个循环看似简单,但all_objects_in_image的构建非常关键。DOTA的原始txt文件,每行是一个目标,格式为x1 y1 x2 y2 x3 y3 x4 y4 class difficulty。我们必须先用空格或制表符将其完整解析,再按行存入列表。很多人用line.split(),结果遇到类别名含空格(如small-vehicle)就崩溃。正确做法是:用正则r'(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)'精确捕获10个字段,前8个转float,第9个是字符串类别,第10个是难度标识。
注意:DOTA的坐标是浮点数,但实际是像素级精度,所以转换后需
round()取整,否则XML中出现123.456789会被某些解析器报错。我们统一加了一行new_coords = [round(c) for c in new_coords]。
3. TXT到XML的字段映射:如何让旋转框在VOC框架里“合法存在”?
Pascal VOC的XML规范,本质上是一个“矩形框优先”的协议。它的<bndbox>节点只接受4个整数:<xmin>、<ymin>、<xmax>、<ymax>。但DOTA的旋转框有8个点,怎么办?答案是:不强行塞进<bndbox>,而是创造一个VOC扩展节点<robndbox>。这是遥感检测社区的共识方案,被MMDetection、DIOR等主流框架原生支持。<robndbox>包含7个字段:cx,cy,w,h,angle,class,difficult,其中angle是弧度制逆时针旋转角(注意:有些框架用角度制,需确认文档)。
所以,TXT转XML的核心,不是简单复制粘贴,而是一次几何降维与语义升维:把8个离散坐标点,拟合成一个中心点(cx,cy)、宽w、高h、旋转角angle的参数化椭圆(实际是外接矩形)。这个过程叫“最小外接旋转矩形拟合”,OpenCV的cv2.minAreaRect函数就是为此而生。它接收8个点,返回一个元组(center, size, angle),其中center是(cx,cy),size是(width,height),angle是旋转角(范围-90°到0°,表示短边与x轴夹角)。
但这里有个致命陷阱:cv2.minAreaRect返回的angle,在OpenCV 4.x中定义为“从x轴正向逆时针旋转到矩形水平边的角度”,而MMDetection要求的是“从x轴正向逆时针旋转到矩形长边的角度”,且范围是-180°到180°。如果不转换,模型会把所有飞机都预测成90度旋转的“竖着的飞机”。我们花了整整一天调试,最终找到转换公式:
# OpenCV返回的angle是-90到0,表示短边角度 # 我们需要长边角度,且范围-180到180 if abs(angle_cv) < 1e-5: # 几乎水平 angle_mmdet = 0.0 else: # 将短边角度转为长边角度:加90度 angle_mmdet = angle_cv + 90.0 # 归一化到-180~180 while angle_mmdet > 180.0: angle_mmdet -= 360.0 while angle_mmdet < -180.0: angle_mmdet += 360.0 # 转为弧度 angle_rad = math.radians(angle_mmdet)有了cx,cy,w,h,angle_rad,XML的<robndbox>就呼之欲出了。一个标准的DOTA XML片段如下:
<annotation> <folder>DOTA</folder> <filename>P0001.png</filename> <source> <database>DOTA</database> </source> <size> <width>600</width> <height>600</height> <depth>3</depth> </size> <segmented>0</segmented> <object> <name>plane</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <robndbox> <cx>320.5</cx> <cy>285.0</cy> <w>120.0</w> <h>45.0</h> <angle>-0.785398</angle> <!-- -45度弧度 --> </robndbox> </object> </annotation>注意几个细节:<folder>和<filename>必须与实际图像路径一致,否则数据加载器找不到图;<size>里的宽高是分割后小图的尺寸,不是原图;<difficult>字段直接映射DOTA txt里的difficulty值(0或1);<truncated>固定为0,因为分割后目标都是完整的。最易错的是<cx>和<cy>——它们是浮点数,但XML标准允许,而某些老旧解析器要求整数。我们的解决方案是在写入前int(round(cx)),并加注释# 为兼容旧解析器,坐标取整。
另外,XML必须严格遵循UTF-8编码,且无BOM。我们用Python的xml.etree.ElementTree生成,最后用tree.write(xml_path, encoding='utf-8', xml_declaration=True)确保声明正确。曾因编码问题,中文系统下生成的XML在Linux服务器上解析失败,错误提示是UnicodeDecodeError,根源竟是Windows记事本默认保存为GBK。
提示:生成XML后,务必用浏览器打开验证。如果显示“XML解析错误”,大概率是某个标签没闭合(如
<object>忘了</object>)或特殊字符未转义(如&要写成&)。我们写了个小脚本,用xml.dom.minidom.parse(xml_path)做预检,捕获异常并打印行号。
4. 工程化落地:一个可复用的分割-转换脚本设计与避坑指南
把上述所有逻辑揉进一个健壮、可配置、易维护的脚本,是项目成败的关键。我们最终交付给某实验室的dota_preprocess.py,核心结构分为三层:配置层、处理层、IO层。这种分层不是为了炫技,而是为了应对真实场景中的频繁变更——比如甲方突然要求步长从400改成300,或新增一个ship类别需要过滤。
配置层(config.py)是整个流程的“控制台”。它用纯Python字典定义所有可调参数:
CONFIG = { 'input_dir': '/path/to/DOTA/Train/labels', 'image_dir': '/path/to/DOTA/Train/images', 'output_dir': '/path/to/processed', 'patch_size': 600, 'stride': 400, 'min_area_ratio': 0.3, # 保留目标面积占原面积比例阈值 'classes_to_keep': ['plane', 'ship', 'storage-tank'], # 只处理这些类 'use_center_point': True, # 是否用中心点判据 }min_area_ratio是个救命参数。它解决的是“小目标被切割后只剩一个点”的问题。比如一个10×10的车辆,被切到边缘,只剩2个顶点在窗口内,面积几乎为0。我们设定:若目标在窗口内的多边形面积小于其原面积的30%,则丢弃。计算面积用Shapely库的Polygon对象,比自己写叉积稳定得多。
处理层(processor.py)是核心引擎。它不直接操作文件,而是定义DOTASplitter和DOTAConverter两个类。DOTASplitter负责分割逻辑,DOTAConverter负责txt转XML。这样设计的好处是:你可以单独测试分割效果——把分割后的坐标点画在小图上,用OpenCV的cv2.polylines可视化,肉眼检查是否准确。我们曾发现一个bug:当窗口右边界等于原图宽度时,range(0, orig_w - patch_size + 1, stride)会少算最后一列,导致边缘目标丢失。修复很简单,把+1改成+stride,确保覆盖到末尾。
IO层(io_utils.py)封装所有文件操作。它提供load_dota_txt(txt_path)和save_voc_xml(xml_path, annotation_dict)两个函数。load_dota_txt内部做了三件事:1)用正则安全解析;2)过滤掉difficulty为2的目标(DOTA中2表示忽略);3)将坐标转为numpy数组,便于后续计算。save_voc_xml则用ElementTree构建树,并自动添加缩进(xml.dom.minidom.parseString(...).toprettyxml()),让生成的XML人眼可读。
整个流程的主函数main(),像一条流水线:
def main(): config = load_config() splitter = DOTASplitter(config) converter = DOTAConverter(config) # 第一步:遍历所有txt文件 for txt_file in glob.glob(os.path.join(config['input_dir'], '*.txt')): image_name = os.path.basename(txt_file).replace('.txt', '.png') image_path = os.path.join(config['image_dir'], image_name) # 第二步:读取原始txt,获取所有目标 objects = io_utils.load_dota_txt(txt_file) # 第三步:分割,得到(小图名,[目标列表])对 patches = splitter.split(image_path, objects) # 第四步:为每个小图生成XML for patch_name, patch_objects in patches: xml_path = os.path.join(config['output_dir'], 'Annotations', patch_name + '.xml') annotation_dict = converter.convert(patch_name, patch_objects) io_utils.save_voc_xml(xml_path, annotation_dict) # 同时复制对应小图到Images目录 patch_img_path = os.path.join(config['output_dir'], 'Images', patch_name + '.png') cv2.imwrite(patch_img_path, patch_img) # patch_img由splitter返回 print("Preprocessing completed.")这个设计最大的优势是可测试性。我们可以为splitter.split写单元测试:给定一个固定坐标和窗口,断言返回的目标列表长度和坐标值。同样,converter.convert也可以用已知的8点坐标,断言生成的<robndbox>字段是否符合预期。我们用pytest写了23个测试用例,覆盖了边界情况(单点、线段、退化四边形),上线后零生产事故。
最后分享三个血泪教训:
路径分隔符陷阱:Windows用
\,Linux用/。脚本在Windows开发,部署到Linux服务器时,os.path.join('a','b')在Windows生成a\b,在Linux生成a/b,但glob.glob在Linux下不认\。解决方案:所有路径拼接统一用pathlib.Path,如Path(config['input_dir']) / '*.txt'。图像通道顺序:OpenCV读图是BGR,而深度学习框架(如PyTorch)默认RGB。如果分割时用OpenCV画框验证,训练时用torchvision读图,颜色就错了。我们强制规定:所有中间图像保存为PNG(无损),且用PIL读写,确保RGB一致性。
类别名大小写:DOTA原始txt中类别是小写(
plane),但有些标注平台导出为大写(Plane)。如果不统一,classes_to_keep匹配失败,所有目标被过滤。我们在load_dota_txt里加了一行class_name = class_name.lower().strip()。
5. 实战验证:在YOLOv8和MMDetection上的效果对比与调参心得
理论再完美,不经过模型检验都是空中楼阁。我们用同一套分割-转换后的数据,在YOLOv8和MMDetection两个主流框架上做了对比实验,目的是验证流程的有效性,并提炼出针对旋转框的调参技巧。实验环境:NVIDIA A100 GPU,PyTorch 2.0,CUDA 11.8。
数据准备:我们选取DOTA的trainval子集(约10万张小图),按8:1:1划分训练/验证/测试集。所有图像尺寸统一为600×600,类别仅保留plane、ship、large-vehicle三个高频类。转换后的XML全部存入VOC2007风格目录(JPEGImages,Annotations,ImageSets/Main)。
YOLOv8实验:YOLOv8原生不支持旋转框,但我们用了Ultralytics社区的yolov8-obb分支(支持oriented bounding box)。训练命令为:
yolo detect train data=dota_obb.yaml model=yolov8n-obb.pt epochs=100 imgsz=600dota_obb.yaml中关键配置:
train: ../dota_processed/Images val: ../dota_processed/Images nc: 3 names: ['plane', 'ship', 'large-vehicle']结果:mAP@0.5达到68.2%,但mAP@0.5:0.95只有32.5%。分析发现,YOLOv8的anchor设计是为水平框优化的,对旋转框的尺度变化适应性差。我们调整了anchors参数,将默认的9个anchor(3组×3个尺度)改为12个(4组×3个),并手动指定宽高比范围(0.2到5.0),mAP@0.5:0.95提升至39.1%。这印证了一个经验:旋转框检测,anchor的宽高比覆盖范围比数量更重要。
MMDetection实验:我们选用RotatedFasterRCNN模型,配置文件基于rotated_faster_rcnn_r50_fpn_1x_dota_le90.py。关键修改:
roi_head.bbox_head.loss_bbox.type = 'RotatedIoULoss'(用旋转IoU替代L1 Loss)train_pipeline中增加Rotate数据增强,随机旋转±15度test_pipeline中flip设为False,因为遥感图像翻转后语义失真(如港口变内陆)
结果:mAP@0.5达到75.6%,mAP@0.5:0.95为45.3%。明显优于YOLOv8,原因在于MMDetection的旋转框头(RotatedBBoxHead)是专为DOTA设计的,其回归目标直接是cx,cy,w,h,angle,没有水平框的中间转换。
最关键的调参心得来自学习率预热(warmup)。旋转框的angle回归对初始学习率极其敏感。我们试过直接用CosineAnnealing,模型在前10个epoch就发散。后来采用LinearWarmup:前500次迭代,学习率从0线性增长到基准值(0.02),之后再衰减。这个改动让训练曲线变得平滑,收敛速度提升30%。
另一个重要发现是难样本挖掘(OHEM)的失效。MMDetection默认开启OHEM,但在旋转框上,它常把角度接近90度的飞机(如侧视图)误判为难样本而过度惩罚,导致模型偏向预测0度或180度。我们关闭OHEM,改用FocalLoss加权,对angle误差大的样本赋予更高权重,效果显著。
最后,关于评估指标的选择:不要只看mAP。我们额外计算了Angle Error(预测角与真实角的绝对差值均值),发现YOLOv8的平均误差是12.3度,而MMDetection是8.7度。这解释了为何后者在高IoU阈值下表现更好——它对方向的把握更准。
经验总结:对于旋转框任务,框架选择比模型结构更重要。YOLOv8适合快速原型验证,MMDetection适合追求SOTA性能。而无论选哪个,
angle的损失函数和学习率策略,都是决定成败的“隐藏开关”。