☰
YOLOv5半自动化标注:txt预标注与VOC xml格式转换全流程
2026/10/22 5:37:16 网站建设 项目流程

做目标检测项目最怕的不是调模型,而是攒数据。攒数据里最磨人的环节是标注:一张图十几个框,框到后面眼睛发花,标错一个还得翻回去改,纯手工标注的效率瓶颈实在明显。半自动化标注(也叫预标注)的思路就是让跑得足够快的模型先出一版初稿,把框、类别、置信度全部生成好,人工只在现成框上做删、改、补,工作量能压掉一大半。这篇文章记录我用YOLOv5做半自动化标注的完整链路,重点讲预标注结果怎么生成,以及YOLO训练的txt标注和可视化用的VOC格式xml标注如何互相转换。

这套方案适合谁?正在做目标检测数据集扩充的人、跨场景迁移后需要快速标注新数据的人、以及想把手头训练好的模型价值再榨一榨的人。我自己在几个项目里用这套链路把初版标注时间从两周压到三天左右,而且模型越用越准,后一批数据的标注成本还会继续降,属于典型的越跑越省力的活。

1. 半自动化标注的整体思路与方案选型

半自动化和"全自动标注"不是一回事。全自动是模型出什么你信什么,直接拿伪标签训练,问题很大——模型会在自己犯错的地方将错就错,误差层层叠加。半自动化的定位是:模型当苦力,人类当质检员。模型负责把一看就知道的框和类别先画出来,人负责修正错框、补漏框、删掉误检,这套流程在工程上才站得住。

1.1 为什么要把"预标注"做进流程

大部分目标检测项目的标注痛点不是"不会标",而是"标不完"。假设你有8000张新场景图片,目标密度平均每张8个,纯手工框选加打类别标签,熟练的标注员一小时大概能处理20到30张,折算下来就是十几个工作日。如果让模型先跑一遍,标注员的任务从"画框+选类别"变成"检查+微调",速度通常能翻三到五倍,而且注意力可以集中在真正难判断的目标上,错误率反而下降。

预标注另一个被低估的价值是快速试错。新场景能不能用现有模型兜底、类别分布长什么样、哪些图片质量太差需要剔除,跑一遍批量推理就能看出来,不需要先花大量人力标注才知道数据值不值得做。

1.2 为什么选YOLOv5作为预标注引擎

选YOLOv5当预标注引擎,主要看中三点。第一是速度,同样一张图在普通桌面级显卡上推理耗时能做到几十毫秒级别,大批量出初稿不卡人;CPU上跑也能接受,只是慢一些,适合没有独立显卡的环境。第二是生态,YOLOv5的训练权重很容易获取,自己数据集上训练好的pt文件可以直接用来推理,不需要额外转换。第三是输出格式天然友好,YOLOv5推理结果的坐标就是xmin、ymin、xmax、ymax,转成训练用的归一化txt标注只需几步计算。

对比其他方案:用更重的检测模型做推理精度可能高一些,但部署成本高、推理慢,批量预标注的体验很差;纯用云端标注服务或外包标注,数据隐私和沟通成本都要考虑,也没有"用自己的模型迭代"的闭环优势。

1.3 标注链路怎么设计:txt和xml双轨并行

整个链路我设计成三个环节:图像目录进模型,批量推理出txt伪标签;txt再转成xml;xml用于人工校核,校核完的数据回流训练,更新模型后重新生成下一版伪标签。

为什么要格式转一道?因为txt和xml各有不可替代的位置。txt是YOLO训练的直接输入,每一行代表一个目标框,格式轻量、读取快,模型训练循环里不会去解析复杂结构。但txt做可视化很痛苦,人工校核时你需要看到框画在图上,还需要标注工具支持导入导出——目前主流开源标注工具对VOC格式xml的支持更成熟,xml里除了坐标还带类别名、图片尺寸、来源等完整信息,方便跨工具协作。所以双轨并行:训练走txt,校核走xml,转换脚本作为中间的桥。

注意:这个链路的正确姿势是"模型先出、人工后修、修完再训",千万别跳过人工校核直接拿伪标签训练。

2. 标注格式核心细节与txt、xml互通原理

转换工具本身十分钟能写完,真正容易翻车的是对格式细节的理解。两个格式的坐标基准完全不同,转换时稍不注意就是整套标注废掉。

2.1 YOLO的txt标注格式拆解

YOLO训练用的txt标注是纯文本,每行代表一个目标,五个字段用空格分隔:

0 0.512000 0.398000 0.324000 0.195000 2 0.698000 0.573000 0.098000 0.267000

第一列是类别索引,从0开始,对应训练配置里names列表的下标。后面四列分别是目标中心点的x坐标、中心点的y坐标、目标宽度、目标高度,而且全部是归一化到0到1之间的相对值,除以了图像的宽和高。

举个例子:图片宽1920、高1080,一个目标中心落在(983, 430),宽622,高210,那txt里记录的就是0.512、0.398、0.324、0.195这四个数。用归一化坐标的好处是模型训练时不关心输入图片的原始尺寸,无论大图小图目标表示都在同一个量纲下,训练更稳定。

2.2 VOC格式xml标注结构拆解

VOC格式xml围绕一个annotation根节点组织信息,其中核心结构如下:

<annotation> <folder>images</folder> <filename>000001.jpg</filename> <path>/data/images/000001.jpg</path> <source> <database>Unknown</database> </source> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <segmented>0</segmented> <object> <name>car</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>329</xmin> <ymin>477</ymin> <xmax>986</xmax> <ymax>1012</ymax> </bndbox> </object> </annotation>

每个字段都有实际用途。filename和path告诉工具和人对哪张图标注;size里记录原始图的宽、高,很多标注工具打开图后会用这个信息做比例换算;object节点每个代表一个目标,name是字符串形式的类别名,bndbox子节点存的是绝对像素坐标——xmin、ymin是左上角,xmax、ymax是右下角,注意这里不是中心点,和txt完全反着。

2.3 txt到xml的坐标换算与类别映射

txt到xml的换算本质是把归一化中心点式表示转成绝对像素的左上角右下角表示:

xmin = (cx - bw / 2) * 图宽 ymin = (cy - bh / 2) * 图高 xmax = (cx + bw / 2) * 图宽 ymax = (cy + bh / 2) * 图高

算出来的值建议做两道保险:一是四舍五入取整,二是裁剪到图像范围内,防止浮点误差导致xmax超出图宽或坐标出现负数。分母上的图宽图高必须和txt对应的是同一张图的真实尺寸,所以转换脚本要自己读图片文件获取尺寸,不能想当然假设所有图一样大。

类别映射是另一个坑。txt里记录的是整数索引,xml里写的是字符串类名,中间必须做一次转换。YOLOv5训练配置里会定义类别顺序,比如names列表第0位是person、第1位是car、第2位是bicycle,那txt里所有0都对应person。转换脚本里维护的这个类别列表必须和训练时的names保持一致,一旦顺序错位,前面训练的模型和转换脚本各说各话,出来的标注就是错乱的。

3. 实操过程与核心代码实现

下面进入能直接抄作业的部分。先说思路,再贴代码,每个关键步骤我都标注了为什么这么做。

3.1 环境准备、模型与待标注数据

环境方面需要Python、PyTorch、OpenCV(或Pillow)这几样基础东西,处理图片、加载模型推理都靠它们。模型直接用你在自己数据集上训练好的YOLOv5权重文件,比如best.pt。没有自己训练好的模型怎么办?先手工标注一批图训练一个初版模型,哪怕只标几百张,精度不需要太高,能框出大概位置就能当预标注引擎用,后面数据越攒越多模型会越来越准。

待标注图片建议统一放在一个目录下,文件名尽量用英文数字组合。中文路径容易让一些库的读取逻辑出问题,尤其在Windows上,踩过这个坑的人都知道。

3.2 批量推理生成txt预标注脚本

核心逻辑很简单:遍历图片文件夹,逐张送入模型推理,拿到检测框坐标和类别索引,换算成归一化格式写入同名txt文件。为了避免把所有图片一次性加载到内存,我用流式逐张处理的方式。

import os import glob # 加载YOLOv5模型 # load_yolo_model为示意写法,实际根据你使用的YOLOv5部署方式替换 model = load_yolo_model('best.pt') model.conf = 0.25 # 置信度阈值,低于该值的框丢弃 model.iou = 0.45 # NMS的IoU阈值,控制重叠框去重 model.max_det = 300 # 单张图最多保留的目标数 img_dir = '待标注图片/' out_dir = '预标注结果/' os.makedirs(out_dir, exist_ok=True) for img_path in glob.glob(os.path.join(img_dir, '*.jpg')): # 读取图片 img = cv2.imread(img_path) h, w = img.shape[:2] # 推理:results是检测结果列表 results = model.predict(img) stem = os.path.splitext(os.path.basename(img_path))[0] txt_path = os.path.join(out_dir, stem + '.txt') with open(txt_path, 'w') as f: for det in results: # 每条det为[x1, y1, x2, y2, conf, cls_id] x1, y1, x2, y2, conf, cls_id = det # 归一化中心点坐标 cx = (x1 + x2) / 2 / w cy = (y1 + y2) / 2 / h bw = (x2 - x1) / w bh = (y2 - y1) / h f.write(f"{int(cls_id)} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}\n") print('已生成:', txt_path)

几个参数的选择经验。置信度阈值0.25是个比较稳的起点,太低会把图片里的纹理误检成目标,生成一堆垃圾框,人工删起来也费时间;太高又会漏掉一部分真实目标,漏掉的人工补框成本比删框高。NMS的IoU阈值保持0.45左右即可,太高会让密集目标的框粘连在一起。如果你的目标特点是小而密集,max_det可以适当调大。

3.3 从txt到xml的批量转换工具

转换脚本我拆成一个单文件转换函数加一个批量处理主函数,这样既方便调试单张图,又能在整个目录上跑批。

import os import glob from PIL import Image import xml.etree.ElementTree as ET # 类别映射列表,必须和YOLOv5训练时的names顺序保持一致 CLASSES = ['person', 'car', 'bicycle'] # 请替换为你的实际类别 def convert_txt_to_xml(txt_path, img_path, xml_path): """把单个YOLO格式txt标注转换为VOC格式xml""" with open(txt_path, 'r') as f: lines = f.readlines() img = Image.open(img_path) width, height = img.size annotation = ET.Element('annotation') folder = ET.SubElement(annotation, 'folder') folder.text = os.path.basename(os.path.dirname(img_path)) or 'images' filename = ET.SubElement(annotation, 'filename') filename.text = os.path.basename(img_path) path = ET.SubElement(annotation, 'path') path.text = img_path source = ET.SubElement(annotation, 'source') database = ET.SubElement(source, 'database') database.text = 'Unknown' size = ET.SubElement(annotation, 'size') w = ET.SubElement(size, 'width') w.text = str(width) h = ET.SubElement(size, 'height') h.text = str(height) d = ET.SubElement(size, 'depth') d.text = '3' segmented = ET.SubElement(annotation, 'segmented') segmented.text = '0' for line in lines: line = line.strip() if not line: continue parts = line.split() if len(parts) != 5: continue cls_id = int(parts[0]) cx = float(parts[1]) cy = float(parts[2]) bw = float(parts[3]) bh = float(parts[4]) # 归一化坐标转绝对像素坐标 xmin = max(0, int((cx - bw / 2) * width)) ymin = max(0, int((cy - bh / 2) * height)) xmax = min(width, int((cx + bw / 2) * width)) ymax = min(height, int((cy + bh / 2) * height)) # 过滤掉转换后宽高非正的异常框 if xmax <= xmin or ymax <= ymin: continue obj = ET.SubElement(annotation, 'object') name = ET.SubElement(obj, 'name') name.text = CLASSES[cls_id] pose = ET.SubElement(obj, 'pose') pose.text = 'Unspecified' truncated = ET.SubElement(obj, 'truncated') truncated.text = '0' difficult = ET.SubElement(obj, 'difficult') difficult.text = '0' bndbox = ET.SubElement(obj, 'bndbox') x1 = ET.SubElement(bndbox, 'xmin') x1.text = str(xmin) y1 = ET.SubElement(bndbox, 'ymin') y1.text = str(ymin) x2 = ET.SubElement(bndbox, 'xmax') x2.text = str(xmax) y2 = ET.SubElement(bndbox, 'ymax') y2.text = str(ymax) tree = ET.ElementTree(annotation) tree.write(xml_path, encoding='UTF-8', xml_declaration=True) def batch_convert(txt_dir, img_dir, xml_dir): """批量转换整个目录""" os.makedirs(xml_dir, exist_ok=True) txt_files = glob.glob(os.path.join(txt_dir, '*.txt')) success_count = 0 skip_count = 0 for txt_path in txt_files: stem = os.path.splitext(os.path.basename(txt_path))[0] # 根据扩展名找同名图片 img_path = None for ext in ['.jpg', '.jpeg', '.png', '.bmp']: candidate = os.path.join(img_dir, stem + ext) if os.path.exists(candidate): img_path = candidate break if img_path is None: print('跳过(找不到图片):', txt_path) skip_count += 1 continue xml_path = os.path.join(xml_dir, stem + '.xml') convert_txt_to_xml(txt_path, img_path, xml_path) success_count += 1 print(f'转换完成:成功 {success_count} 个,跳过 {skip_count} 个') if __name__ == '__main__': batch_convert('预标注结果/', '待标注图片/', 'xml标注/')

这里有几个值得注意的实现细节。读图片尺寸我用了Pillow而非OpenCV,因为批量场景下Pillow对异常图片处理更稳。字符串手拼XML很容易出结构错误,我用ElementTree构造再序列化,能确保格式合法。空txt文件转换后xml里没有object节点,只保留annotation基础信息,这样标注工具能正常打开,不会直接报错。

3.4 人工校核与数据回流闭环

转换完xml后,用支持VOC格式的开源标注工具打开图片和xml,人工校核就开始了。我的建议是别挨张顺序看,先按置信度从低到高排——低置信度的框大概率是误检或者框得不准,优先处理这些;高置信度的框快速扫一眼带过。这个顺序能让人工注意力集中在真正的问题区域,效率比从头到尾一张一张过要高不少。

校核完的xml已经包含了人工修正结果,下一步是从xml再转回txt用于训练。写一个逆向转换脚本,读取object节点的name和bndbox,把类名映射回索引,坐标按宽高归一化输出,就完成回流了。至此,"模型跑预标注 → 人工校核 → 数据回流训练 → 更新模型 → 再跑预标注"的闭环就转起来了。每轮循环后模型都会比上一轮更适应数据分布,标注成本随之递减。

4. 常见问题与排查经验

这套链路跑过几轮之后,最容易出问题的位置基本集中在坐标、类别映射和xml本身这三块。下面是我踩过且帮你排过雷的集中排查经验。

4.1 坐标位置错位问题

现象是框的位置和图片里的目标整体偏移,比如所有框往右下方偏了一段距离,或者框比实际目标大一圈。最常见原因是模型推理时用了letterbox预处理——先把图片等比缩放并填充到固定尺寸再进网络,如果直接用letterbox后的坐标去还原原图,不做unpad还原,位置必然错乱。

排查方法:随便挑一张图,把模型输出框可视化画在图上,肉眼确认坐标是否贴住目标。如果错位,检查你使用的推理接口是否已经帮你还原到原图坐标。正规的实现都会在输出前做坐标映射,如果你自己写了预处理,就要手动把缩放和填充的补偿算回去。

4.2 类别映射错乱问题

现象是框的位置都对,但类别名字张冠李戴——把车标成人,把人标成自行车。这类问题最隐蔽,因为光看坐标根本发现不了。原因基本只有一个:转换脚本里的CLASSES列表和训练时的names顺序不一致。

两个自查方法。第一,打印模型输出的类别索引分布,和训练数据的标签分布做对比,看类别比例是否合理。第二,找一张人比较少、目标类别单一的图单独跑一遍,看输出类别名是否符合常识。保持CLASSES和训练配置同步,是这条链路的红线,建议把它写进数据配置的yaml文件统一管理,转换脚本从同一个配置里读,避免两边各自维护一份。

4.3 XML文件打不开和格式报错

用文本编辑器打开xml显示乱码,或者标注工具直接报"invalid annotation",多半是XML结构不完整或者编码问题。手拼字符串容易漏掉闭合标签,一个object节点忘记闭合,整个文件就废了。

用ElementTree生成可以有效避免这个问题,因为树形结构在序列化时会自动保证标签闭合。保存时显式指定encoding='UTF-8'和xml_declaration=True,防止中文类名在其他编码环境下乱码。

4.4 空标注文件与漏检问题

批量推理跑完,发现不少图片的txt是空文件或者xml里没有任何object,先别急着认为是模型太差。多数情况是置信度阈值设高了,模型其实有预测,只是分数没过线被滤掉。把阈值降到0.1重新跑一次,对比一下召回率提升多少,如果提升明显,说明这批图片的目标形态对现有模型来说确实模糊,人工补框时要有心理准备。

反过来,如果低置信度下生成大量垃圾框,也不需要硬扛。在txt转xml的脚本里可以根据置信度做二次过滤——推理脚本生成txt时保存了confidence字段,不过txt的五个字段没有位置放它,所以更推荐的做法是推理脚本里先过滤一遍再写txt,避免污染标注格式。

4.5 批量处理过程中的常见坑速查表

问题现象主要原因解决方案
文件名匹配失败某张图的xml没生成图片后缀大小写不同后缀匹配时统一转小写比较
中文路径乱码图片打不开或尺寸读取为0部分库对非ASCII路径支持差路径临时改为英文,或用Pillow读取
转换后坐标越界框超出图外浮点误差和四舍五入做min/max裁剪,过滤宽高非正框
图片损坏推理中断或尺寸异常个别图片文件损坏读取时加异常捕获,跳过并记录日志
显存溢出大批量推理中途崩掉单次加载过多图片流式逐张处理,不一次性读入全部图片

批量跑标注时还有一个小习惯很值得养成:每跑完一批,随机抽5%的图片做人工复核,逐张确认框和类别没有系统性错误。一次抽查可能花十分钟,但能防止转换逻辑的某个bug毁掉整个数据集的标注,这笔时间花得相当值。

我自己做下来的体会是,标注痛苦越大的环节,预标注带来的收益越明显。数据量小的时候这套流程看不出优势,数据量一旦上千张,半自动化配合人工校核几乎成了必经之路。最后分享一个实战小技巧:当模型升级后不要对着旧标注逐张手工改,直接对全量数据重新跑一遍预标注,再统一做人工校核,比增量修改旧标注要快得多,也干净得多。等这套流程跑顺之后,你会发现手头模型的价值被放大了不止一倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询