YOLOv5半自动化标注:txt预标注与VOC xml格式转换全流程
做目标检测项目最怕的不是调模型而是攒数据。攒数据里最磨人的环节是标注一张图十几个框框到后面眼睛发花标错一个还得翻回去改纯手工标注的效率瓶颈实在明显。半自动化标注也叫预标注的思路就是让跑得足够快的模型先出一版初稿把框、类别、置信度全部生成好人工只在现成框上做删、改、补工作量能压掉一大半。这篇文章记录我用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再转成xmlxml用于人工校核校核完的数据回流训练更新模型后重新生成下一版伪标签。为什么要格式转一道因为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 folderimages/folder filename000001.jpg/filename path/data/images/000001.jpg/path source databaseUnknown/database /source size width1920/width height1080/height depth3/depth /size segmented0/segmented object namecar/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin329/xmin ymin477/ymin xmax986/xmax ymax1012/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_okTrue) 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, encodingUTF-8, xml_declarationTrue) def batch_convert(txt_dir, img_dir, xml_dir): 批量转换整个目录 os.makedirs(xml_dir, exist_okTrue) 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生成可以有效避免这个问题因为树形结构在序列化时会自动保证标签闭合。保存时显式指定encodingUTF-8和xml_declarationTrue防止中文类名在其他编码环境下乱码。4.4 空标注文件与漏检问题批量推理跑完发现不少图片的txt是空文件或者xml里没有任何object先别急着认为是模型太差。多数情况是置信度阈值设高了模型其实有预测只是分数没过线被滤掉。把阈值降到0.1重新跑一次对比一下召回率提升多少如果提升明显说明这批图片的目标形态对现有模型来说确实模糊人工补框时要有心理准备。反过来如果低置信度下生成大量垃圾框也不需要硬扛。在txt转xml的脚本里可以根据置信度做二次过滤——推理脚本生成txt时保存了confidence字段不过txt的五个字段没有位置放它所以更推荐的做法是推理脚本里先过滤一遍再写txt避免污染标注格式。4.5 批量处理过程中的常见坑速查表问题现象主要原因解决方案文件名匹配失败某张图的xml没生成图片后缀大小写不同后缀匹配时统一转小写比较中文路径乱码图片打不开或尺寸读取为0部分库对非ASCII路径支持差路径临时改为英文或用Pillow读取转换后坐标越界框超出图外浮点误差和四舍五入做min/max裁剪过滤宽高非正框图片损坏推理中断或尺寸异常个别图片文件损坏读取时加异常捕获跳过并记录日志显存溢出大批量推理中途崩掉单次加载过多图片流式逐张处理不一次性读入全部图片批量跑标注时还有一个小习惯很值得养成每跑完一批随机抽5%的图片做人工复核逐张确认框和类别没有系统性错误。一次抽查可能花十分钟但能防止转换逻辑的某个bug毁掉整个数据集的标注这笔时间花得相当值。我自己做下来的体会是标注痛苦越大的环节预标注带来的收益越明显。数据量小的时候这套流程看不出优势数据量一旦上千张半自动化配合人工校核几乎成了必经之路。最后分享一个实战小技巧当模型升级后不要对着旧标注逐张手工改直接对全量数据重新跑一遍预标注再统一做人工校核比增量修改旧标注要快得多也干净得多。等这套流程跑顺之后你会发现手头模型的价值被放大了不止一倍。