尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

VOC转YOLO全解析:从XML到TXT标注的格式转换与避坑指南

VOC转YOLO全解析:从XML到TXT标注的格式转换与避坑指南 做目标检测的十有八九都绕不开VOC转YOLO这一关。我第一次用LabelImg辛苦标了一周数据存下来全是PascalVOC的XML文件等真正想把它们喂给YOLO训练时才发现YOLO各主流框架根本不认这种XML它要的是每张图一个txt、每行一个“类别编号归一化坐标”的标注文件。当时网上教程翻了一堆要么直接丢个脚本让人自己跑要么脚本跑到一半就报错根本没有把原理讲清楚。这篇就把“VOC转YOLO”从头到尾讲透为什么转、原理是什么、代码怎么写、转完怎么验证、有哪些坑。适合手里有VOC存量数据、或者正打算自己打标训练YOLO模型的人参考。1. 为什么必须转格式VOC与YOLO的底层差异1.1 VOC的XML标注到底存了什么PascalVOCVisual Object Classes格式是目标检测领域最经典的标注格式之一LabelImg、labelme这类工具默认保存的往往就是它。一个标准的VOC标注文件长这样annotation folderJPEGImages/folder filename000001.jpg/filename source databaseUnknown/database /source size width500/width height375/height depth3/depth /size segmented0/segmented object nameperson/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin42/xmin ymin30/ymin xmax260/xmax ymax360/ymax /bndbox /object /annotation这里面对训练有用的核心信息就三块size里的图片宽高、object里的类别名name、bndbox里的四个坐标。其他像pose、truncated、difficult这些字段转换的时候根本用不上直接忽略就行。有一点很容易被忽略XML里的坐标是像素坐标而且标注框的宽高没有直接给出需要你用xmax - xmin和ymax - ymin自己算。也就是说XML记录的是一种“绝对位置”描述锚定的是某一张固定分辨率的图片。1.2 YOLO的txt标注到底存了什么YOLO系列的目标检测标注格式非常简洁一张图片对应一个同名txt文件文件里每一行代表一个目标一共五个数0 0.3125 0.5000 0.2500 0.3000从左到右依次是类别编号整数从0开始目标框中心点的x坐标相对图片宽度归一化目标框中心点的y坐标相对图片高度归一化目标框的宽度相对图片宽度归一化目标框的高度相对图片高度归一化注意这里的四个坐标值全部是0到1之间的浮点数不是像素值这就是VOC和YOLO两种格式之间最根本的差异一个存的是绝对像素坐标一个存的是相对归一化坐标。转换的核心动作就是把像素坐标换算成相对坐标再把类别名换成类别编号。1.3 坐标归一化才是转换的真正核心归一化的公式并不复杂用一次就能记住。假设图片宽为W、高为HXML里给的框是(xmin, ymin, xmax, ymax)那么x_center (xmin xmax) / 2 / W y_center (ymin ymax) / 2 / H box_w (xmax - xmin) / W box_h (ymax - ymin) / H拿前面XML里的数据举例假设图片实际分辨率是500x375(xmin, ymin, xmax, ymax) (42, 30, 260, 360)x_center (42 260) / 2 / 500 0.302 y_center (30 360) / 2 / 375 0.520 box_w (260 - 42) / 500 0.436 box_h (360 - 30) / 375 0.880所以最终写入txt的就是0 0.302 0.520 0.436 0.880。这里0是类别编号对应训练配置里person的索引。为什么要用归一化坐标因为YOLO在训练时会对输入图片做resize比如统一缩放到640x640。如果标注存的是绝对像素值图片缩放之后框就全错位了但归一化坐标是一个比例图片怎么放大缩小中心点和宽高的相对比例都不变。这个设计是整个YOLO格式能稳定工作的基石。另外还要注意有些老教程在转换时会建议“坐标减1”理由是PascalVOC的坐标从1开始而程序里从0开始。我的建议是别加这步。绝大多数现代工具和框架对这一个像素的偏差完全不敏感反而很多人在坐标上做加减法之后容易出现负数或者边界溢出纯属给自己挖坑。2. 转换前先想明白这三件事2.1 数据集体检转换前必须清理的脏数据很多人拿到一批VOC数据就着急写脚本结果转换完训练的时候一堆报错回头查才发现源数据本身就有问题。转换前花十分钟做一次“体检”能省下后面大量排查时间。第一检查每个XML是否有对应的图片文件。实际项目中经常出现这种情况标注好了但图片被误删或者从别人那里拷贝数据时只拷了Annotations目录。转换脚本应该自动检查配对情况缺图标的标注就是无效数据。第二检查XML里的size是否和真实图片分辨率一致。如果标注时工具写入的尺寸和实际图片不一致转换出来的归一化坐标全都会错位。这个错误隐蔽性很强因为转换脚本本身不会报错框也能画出来就是位置不对。第三检查有没有空的XML。有些标注工具中断会导致XML文件存在但没有任何object节点这种情况转换出来的txt也是空的。空txt本身不算致命但如果数量太多说明数据质量堪忧最好回源排查。第四统计所有XML中出现的类别名看看有没有意外类名。比如你标了“person”但手滑把一张图标成了“Person”大小写不一致在类别映射表里就会被当成另一个类而你不会第一时间发现。2.2 类别映射表80%的错都错在这里YOLO不识别类别名字只认编号。所以转换的时候必须把“person”对应到0、“car”对应到1这个对应关系要和你之后训练时用的data.yaml或names文件完全一致。最容易出错的做法是写一个自动发现类别的逻辑遍历XML过程中遇到新类就分配一个编号。这种脚本在“单次运行、单批数据”时看起来没问题但只要你改了文件读取顺序、追加了新类别数据、或者换了一个机器跑编号顺序就全变了。更阴间的是训练过程不报错、loss也正常下降但模型训练完把人的检测结果当成车这种bug排查起来极其痛苦。我推荐的稳妥做法是先手动把类别集合整理出来固定写好映射表。比如CLASS_MAP { person: 0, car: 1, bicycle: 2, dog: 3, }然后把这个映射表配套训练配置里的names文件一起放在数据集目录里所有环节共用这一份顺序。以后加了新类别统一在映射表和names文件里同步更新而不是靠脚本自动分配。2.3 目录结构规划直接决定训练能不能跑起来VOC转YOLO不只是改标注格式还有一个容易被忽略的步骤数据集目录结构调整。不同YOLO框架对数据集组织方式的要求不一样转之前先决定用哪个框架训练。如果你用的是Ultralytics YOLOv5/YOLOv8/yolo11这类框架推荐组织成这种结构datasets/ └── mydata/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yaml其中data.yaml内容大致是train: images/train val: images/val nc: 4 names: [person, car, bicycle, dog]如果你用的是Darknet原版YOLO结构稍微不同通常是一张图片路径列表文件加上labels目录mydata/ ├── Annotations/ ├── JPEGImages/ ├── labels/ ├── train.txt └── val.txttrain.txt里每一行是训练图片的绝对路径模型会根据图片路径自动找同名的txt标注文件。我的建议是如果不是因为什么特殊原因必须用Darknet就直接用Ultralytics风格因为它的数据加载逻辑更简单几乎成了现在主流的默认约定。目录结构设计这一步千万不能省我见过很多人辛辛苦苦转好了标注最后因为images和labels目录对不上训练时一个样本都读不到。3. 手写一个可靠的VOC转YOLO转换器3.1 环境准备与脚本整体设计转换脚本用Python写最方便核心解析部分只需要标准库xml.etree.ElementTree连第三方依赖都不需要。我做数据校验时会用到OpenCV来画框检查所以建议提前装好pip install opencv-python脚本整体设计分四个函数解析单条XML、转换单个标注文件、批量转换整个目录、可视化校验。这样分层的好处是每个函数职责单一出问题的时候能快速定位。我下面给的脚本可以直接复制使用路径改成自己的就行。3.2 核心转换代码逐段讲透先看核心转换部分import os import xml.etree.ElementTree as ET from pathlib import Path CLASS_MAP { person: 0, car: 1, bicycle: 2, dog: 3, } def parse_voc_xml(xml_path): tree ET.parse(str(xml_path)) root tree.getroot() size root.find(size) if size is None: raise ValueError(f{xml_path} 缺少 size 节点) width int(size.find(width).text) height int(size.find(height).text) if width 0 or height 0: raise ValueError(f{xml_path} 图片尺寸异常: {width}x{height}) objects [] for obj in root.findall(object): name obj.find(name).text.strip() bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) if xmax xmin or ymax ymin: print(f警告: {xml_path} 中 {name} 的框宽高异常已跳过) continue objects.append({name: name, bbox: (xmin, ymin, xmax, ymax)}) return width, height, objects def convert_one(xml_path, output_dir, class_map): width, height, objects parse_voc_xml(xml_path) label_path Path(output_dir) / (Path(xml_path).stem .txt) lines [] for obj in objects: class_name obj[name] if class_name not in class_map: print(f警告: 类别 {class_name} 不在CLASS_MAP中目标被忽略) continue class_id class_map[class_name] xmin, ymin, xmax, ymax obj[bbox] x_center (xmin xmax) / 2.0 / width y_center (ymin ymax) / 2.0 / height box_w (xmax - xmin) / width box_h (ymax - ymin) / height x_center min(max(x_center, 0.0), 1.0) y_center min(max(y_center, 0.0), 1.0) box_w min(max(box_w, 0.0), 1.0) box_h min(max(box_h, 0.0), 1.0) lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}) label_path.parent.mkdir(parentsTrue, exist_okTrue) label_path.write_text(\n.join(lines) (\n if lines else ), encodingutf-8) return len(lines)这段代码里有两个细节是我特意加的。第一是宽高异常检查如果xmax xmin说明这个框没有有效宽度直接跳过不然归一化后会出现0甚至负数宽度训练时轻则警告重则loss变成NaN。第二是数值clamp把归一化结果限制在0到1之间防止边缘目标框超出图片边界时算出大于1或小于0的坐标。这里再说一下convert_one函数的命名规范和匹配逻辑它输出的txt文件名用的是XML的文件名Path(xml_path).stem而不是XML内部filename字段的值。这是故意这么做的。因为XML内部的filename经常和实际文件名对不上但Annotations目录下的XML文件名本身就和图片文件名一一对应LabelImg保存时就是同名.xml所以直接用文件系统里的名字最可靠。3.3 批量转换、数据集划分一步到位单个文件转换没问题之后写一个批量处理函数def convert_all(annotations_dir, labels_dir, class_map): annotations_dir Path(annotations_dir) labels_dir Path(labels_dir) xml_files sorted([p for p in annotations_dir.iterdir() if p.suffix.lower() .xml]) total len(xml_files) success 0 empty_count 0 for idx, xml_path in enumerate(xml_files, 1): try: count convert_one(xml_path, labels_dir, class_map) success 1 if count 0: empty_count 1 except Exception as e: print(f[{idx}/{total}] 转换失败 {xml_path.name}: {e}) print(f转换完成: 成功 {success}/{total}, 其中无有效目标 {empty_count} 个)调用方式很简单if __name__ __main__: convert_all( annotations_dirVOCdevkit/VOC2007/Annotations, labels_dirdataset/labels, class_mapCLASS_MAP )转换完之后如果你用的是Ultralytics框架还需要把图片和标注按train/val分到images和labels目录。这里给一个简单的划分脚本import random import shutil from pathlib import Path def split_dataset(image_dir, label_dir, output_dir, val_ratio0.2, seed42): image_dir Path(image_dir) label_dir Path(label_dir) output_dir Path(output_dir) images [] for ext in (*.jpg, *.jpeg, *.png, *.bmp, *.JPG, *.JPEG, *.PNG): images.extend(list(image_dir.glob(ext))) # 只保留有对应标注的图片 valid_images [p for p in images if (label_dir / (p.stem .txt)).exists()] random.seed(seed) random.shuffle(valid_images) val_num int(len(valid_images) * val_ratio) val_list valid_images[:val_num] train_list valid_images[val_num:] for split_name, split_list in [(train, train_list), (val, val_list)]: img_out output_dir / images / split_name lab_out output_dir / labels / split_name img_out.mkdir(parentsTrue, exist_okTrue) lab_out.mkdir(parentsTrue, exist_okTrue) for img_path in split_list: shutil.copy2(img_path, img_out / img_path.name) shutil.copy2(label_dir / (img_path.stem .txt), lab_out / (img_path.stem .txt)) print(f{split_name}: {len(split_list)} 张) print(f数据集划分完成输出目录: {output_dir})这个脚本比直接改文件路径更保险的地方在于它先检查“有图片且必须有对应标注”把那些有图没标、有标没图的脏数据自动过滤掉。seed42保证每次运行随机结果一致方便复现。3.4 可视化校验转完不验证等于白转转换完不等于完成我强烈建议至少抽20%的图片做一次可视化校验。最直接的方法是用OpenCV读图片、读txt、把框画出来肉眼检查一遍。import cv2 def draw_yolo_boxes(image_path, label_path, class_names): img cv2.imread(str(image_path)) if img is None: print(f图片读取失败: {image_path}) return h, w img.shape[:2] with open(label_path) as f: lines f.readlines() for line in lines: parts line.strip().split() if len(parts) 5: continue cls_id int(parts[0]) x_center float(parts[1]) y_center float(parts[2]) box_w float(parts[3]) box_h float(parts[4]) x1 int((x_center - box_w / 2) * w) y1 int((y_center - box_h / 2) * h) x2 int((x_center box_w / 2) * w) y2 int((y_center box_h / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) label_text f{class_names[cls_id]} {cls_id} cv2.putText(img, label_text, (x1, max(y1 - 5, 0)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite(verify_ Path(image_path).name, img) # 示例: 检查一张图 draw_yolo_boxes( image_pathdataset/images/train/000001.jpg, label_pathdataset/labels/train/000001.txt, class_names[person, car, bicycle, dog] )画出来的框如果贴合目标、类别名正确、没有出现框跑到图片外面的情况说明转换没问题。反之如果框大面积偏移优先检查XML里的size和图片实际分辨率是否一致。4. 常见问题排查与避坑实录4.1 问题速查表我把这些年踩过的坑整理成一张表遇到问题直接对号入座现象大概率原因解决办法训练时一个样本都读不到images和labels目录不对应或图片扩展名不被支持检查目录层级统一图片后缀框全部画在图片左上角一个点归一化坐标算错或者可视化时误用了resize后的宽高核对公式确认使用原始图片尺寸类别标签全部错位类别映射表顺序和训练配置names不一致全流程共用同一份类别顺序训练loss为NaN标注中含0宽高框、坐标为负数转换时跳过异常框增加数值检查某张图训练时没有目标XML里object为空或类别名未在映射表中检查源XML统一类别名OpenCV读图片返回None路径含中文、图片文件损坏或格式不支持改成纯英文路径检查文件完整性4.2 案例一class_id全乱了之前帮一个朋友排查过模型效果奇差的问题。他训练了一个检测人和车的模型训练过程一切正常loss也降了但推理结果里标着“person”的框全落在车上。排查到最后发现他的转换脚本用的是“自动发现类别并编号”某次脚本文件读取顺序变了person从0变成了1而训练配置里person还是0。转换脚本不报错、训练不报错模型也正常收敛但它学到的标签和真实标签错位了。这是一个非常难查的bug因为所有中间环节看起来都正常。教训就是类别编号必须由一份固定文件统一定义不要在转换脚本里临时生成。4.3 案例二训练loss不收敛坐标越界惹的祸还有一种情况是坐标越界。标注工具允许你画到图片外面比如图片宽640但框的xmax标成了645。归一化之后这个值大于1YOLO在计算anchor匹配和损失时会遇到奇怪的数值严重时loss直接出现NaN。我见过有人因为这个调了一周学习率最后才发现是数据里十几个越界框搞的鬼。所以建议在转换之前先统计一下源XML中有多少框越界import xml.etree.ElementTree as ET from pathlib import Path def check_out_of_bounds(xml_dir): xml_dir Path(xml_dir) bad_files [] for xml_path in xml_dir.glob(*.xml): root ET.parse(str(xml_path)).getroot() size root.find(size) if size is None: continue width int(size.find(width).text) height int(size.find(height).text) for obj in root.findall(object): b obj.find(bndbox) xmin float(b.find(xmin).text) ymin float(b.find(ymin).text) xmax float(b.find(xmax).text) ymax float(b.find(ymax).text) if xmin 0 or ymin 0 or xmax width or ymax height: bad_files.append(str(xml_path)) break print(f发现越界文件: {len(bad_files)} 个) for f in bad_files: print(f)这个脚本可以批量定位问题文件再去LabelImg里手动修正比训练时抓瞎强多了。4.4 案例三文件对不上训练时一个样本都读不到还有一个高频问题出现在图片和标注文件配对阶段。常见场景是XML的filename字段写的是001.jpg但实际文件名已经被改成001_1.jpg如果你的转换脚本依赖filename去拷贝图片就会找不到文件。我的处理原则是XML内部的文件名只做参考不作为配对依据。以Annotations目录下的XML文件名为准图片文件则用同样的stem去查找找到就配对找不到就输出警告。这样即使文件名被改过只要XML没动转换依然能正确工作。4.5 那些网上教程没告诉你的坑最后分享几个容易被忽略的小坑。第一LabelImg支持在保存时直接切换格式。如果你还没开始标注在LabelImg右上角或快捷键设置里把保存格式从PascalVOC改成YOLO直接省掉一次转换。存量数据才需要脚本转新数据尽量直接产出YOLO格式。第二有些从工具导出的XML会带命名空间比如object变成object xmlns...。这种情况下root.findall(object)返回空列表怎么都解析不到。碰到这种情况可以用{namespace}object这种带花括号的写法去查找或者先审查一下XML根节点有没有xmlns属性。第三图片后缀大小写问题。.JPG和.jpg在Linux下是不同文件很多划分脚本只用小写glob会把大写后缀的图片漏掉。代码里最好把常见大小写后缀都加上。第四转换前记得备份原始VOC数据。转换脚本再怎么写了容错也有可能在源数据上直接覆盖出问题。我一般会把VOC原始目录完整保留转换输出到一个全新目录这样无论后面训练遇到什么异常都能随时回滚重新生成。5. 一些经验总结转换这件事的最后一公里做目标检测这几年前前后后转过的数据集加起来有十几个G。要说最深刻的体会就两个第一转换脚本本身确实不难难的是数据里各种“不干净”真正消耗时间的永远在数据清理上第二不管脚本写得再好转完了一定要抽一批图片画框肉眼核对这一步省不得。你训练半天发现效果不对往往不是模型的问题而是标注在第一步就埋了雷。另外如果你的数据集特别大可以考虑给转换脚本加上多进程支持用multiprocessing.Pool按XML文件并行转换速度会有明显提升如果图片数量上万记得检查磁盘空间够不够别转换到一半空间满了。这个小细节看起来不起眼但我在实际项目中吃过亏。希望这篇能把VOC转YOLO这件事给你讲透少走点我走过的弯路。
返回列表