ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

更改YOLO数据集标签的Python脚本:类别映射与坐标变换指南

更改YOLO数据集标签的Python脚本:类别映射与坐标变换指南 简介一份专门用于批量修正YOLO数据集标注的Python小工具面向目标检测模型训练中常见标签类别编号不一致的场景。当标注文件中的类别1实际对应着类别0例如0对应cat、1却误标为cat或需要将验证集、训练集中的指定类别统一替换时运行该脚本即可完成批量修改省去手动打开每个txt逐一替换的重复劳动适合使用LabelImg、YOLOMark等标注工具后需要统一类别id的开发者与算法工程师。资源包采用zip格式压缩包体仅487B共包含1个.py文件代码简洁无需复杂依赖可直接在本地Python环境运行也能根据实际标签映射关系修改后复用。已有321人学习下载是整理YOLO数据集时非常轻量且即取即用的效率工具。1. 为什么需要“更改YOLO数据集标签”的脚本训练 YOLOv8 时你大概率遇过这种情况开源数据集的类别排序和自己项目的类别表完全对不上或者标注完一轮后发现某个类别编号标错了几百个 txt 标签文件躺在目录里里面全是“0 0.5000 0.5000 0.3100 0.4200”这样的归一化坐标想手工改正几乎不可能。更麻烦的是直接在文本编辑器里按“1”查找、全部替换成“2”坐标里的浮点数也会被一并改写整个数据集直接作废。这时用 Python 写一个标签改写脚本把“读标签、改类别、写回”做成自动化流程是唯一稳定可靠的做法。脚本要覆盖三类高频需求类别映射、按类别过滤、坐标变换同时保证不误伤坐标。适合正在做目标检测数据清洗、从开源数据集抽取子集或者要把旧模型标注迁移到新类别体系的人。2. 先读懂YOLO标签格式txt归一化坐标与类别编号YOLO 标签格式在 YOLOv5、YOLOv8、YOLO11 这些主流版本里保持一致每个目标占一行文本五个字段按空格分隔依次是类别 ID、中心点 x、中心点 y、框宽、框高。写“更改YOLO数据集标签”脚本的第一步就是把这一行安全地拆开确认每个字段的类型和取值范围否则后续替换类别编号时很容易把坐标也改掉。2.1 YOLO标签的一行到底写了什么以“0 0.5 0.5 0.31 0.42”为例类别 ID 是 0框中心点位于图片宽度的 50%、高度的 50%框宽度占整张图的 31%高度占 42%。因为坐标基于图片宽高做了归一化标签与具体分辨率解耦1920×1080 和 640×360 的图片可以共用同一套标签文本这是 YOLO 格式比 VOC 的 xml 标注更适合直接训练的原因。字段含义取值范围示例第1个数字类别 ID0 到类别总数减 10第2个数字中心点 x 归一化0 到 10.5第3个数字中心点 y 归一化0 到 10.5第4个数字框宽度归一化0 到 10.31第5个数字框高度归一化0 到 10.42注意“0 到 1”并不是硬约束部分标注工具会因浮点数精度产生 1.000001 或 -0.000001 这类值脚本改写时可以做边界裁剪。裁剪策略不是无脑截断截断后框与原始目标的贴合关系也会变化所以校验阶段要看整体统计而不是只盯单个数值。2.2 标签文件与图片的对应关系YOLO 数据集里标签和图片靠文件名关联。常见目录结构是 images/train/xxx.jpg 与 labels/train/xxx.txt 一一对应txt 里每行描述一张图片里的一个目标。做标签改写前先确认数据集目录层级很多脚本在遍历时忽略了 train/val 子目录只处理了第一层导致训练时半数标签没被改动排查起来非常浪费时间。还有一个容易忽略的点图片经过 resize 后归一化标签依然成立但图片一旦被裁剪或旋转后没有同步更新标签坐标就全部失效。给数据集做标签改写时如果同时伴随图像变换应该把图像处理和标签处理放在同一个脚本流程里避免标签改完、图像又变造成错位。2.3 用正则在文本里直接替换的三大陷阱拿到这个需求第一反应往往是打开编辑器做“全部替换”。这个做法有三个坑第一替换目标不精确把 1 改成 2 时坐标里的浮点数 1.0、0.71 会被波及第二txt 标签行用空格分隔多替换一位数字坐标直接越界第三纯文本替换不检查越界和类别合法性改完连报错都没有等训练时才发现 loss 异常。# 错误示范把“1”替换成“2”会发生什么 line 1 0.7000 0.5000 0.1523 0.9000 bad line.replace(1, 2) print(bad) # 输出2 0.7000 0.5000 0.2523 0.9000这个输出里框宽从 0.1523 变成 0.2523目标范围被整体带偏。正确做法是先按空白切分再单独对第 0 个字段做整数替换def parse_yolo_line(line): parts line.strip().split() if len(parts) 5: return None cls int(parts[0]) x, y, w, h map(float, parts[1:5]) return cls, x, y, w, hline.strip().split()把连续空格或 tab 统一按空白符切分得到稳定的五个字段。int(parts[0])将类别 ID 转成整数map(float, parts[1:5])把坐标转成浮点数为后续改写提供结构化数据。提示部分标注工具会在行尾写入多余空格或空行解析时先执行 strip()遇到空行直接跳过。3. 用Python写批量标签改写脚本最小可运行版本3.1 核心思路解析→映射→写回标签改写脚本的最简流程只有三步读入每个 txt 文件逐行解析成“类别 四个坐标”按映射表处理类别或坐标把结果写回。整条链路的实现原则是不要在原文件上原地修改而是输出到新目录原始标注可以回溯出错时能直接对比差异。具体执行顺序如下用 pathlib 遍历标签目录收集所有 .txt 文件逐行读取用上一章的 parse_yolo_line 拆分字段判断该行是否需要保留、类别是否要映射、坐标是否要变换把处理后的字段重新用空格拼接写入输出文件统计处理前后各类别框数量对比映射关系是否正确。第 4 步的“重新拼接”看似简单实际上比“只改一个数字”稳妥得多。每个字段都经过类型转换后用统一格式写回可以消除原始文件中多余空格、行尾空白这类干扰。3.2 最小实现类别替换脚本直接可跑的类别替换版本如下假设要把所有类别 2 改成类别 5from pathlib import Path def rewrite_labels(src_dir, dst_dir, cls_map): src Path(src_dir) dst Path(dst_dir) dst.mkdir(parentsTrue, exist_okTrue) for txt in src.glob(*.txt): out_lines [] with open(txt, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue parts line.split() old_cls int(parts[0]) if old_cls not in cls_map: continue parts[0] str(cls_map[old_cls]) out_lines.append( .join(parts)) out_path dst / txt.name with open(out_path, w, encodingutf-8) as f: f.write(\n.join(out_lines) \n) if __name__ __main__: rewrite_labels( src_dirlabels/train, dst_dirlabels_train_r1, cls_map{2: 5}, )src.glob(*.txt)只匹配当前目录下的 txt 文件适合标签都平铺在一个目录里的情况。cls_map{2: 5}表示“原类别 2 改成类别 5”不在映射里的旧类别默认整行丢弃这个行为需要在文档里写清楚避免误删。脚本只改了第 0 个字段坐标原样保留所以适用范围是“单纯重编号”。如果还要同时调整框的大小需要接上坐标变换逻辑。3.3 路径收集glob与os.walk怎么选大多数 YOLO 数据集都有 train/val 两个子目录少数还有 test。glob(*.txt)会漏掉子目录里的文件稳妥的做法是使用 rglob 递归匹配或者用 os.walk 逐层遍历。rglob 在路径收集阶段代码更短os.walk 则适合需要在遍历时感知目录层级、按子目录名做不同处理的场景。from pathlib import Path def collect_label_files(label_root): root Path(label_root) files sorted(root.rglob(*.txt)) return filesrglob(*.txt)递归匹配所有后缀为 .txt 的文件sorted保证输出顺序稳定。对 train/val 分离的数据集把label_root指向 labels 目录即可。参数类型作用src_dirstr原始标签目录脚本只从该目录读取dst_dirstr输出标签目录不存在时自动创建cls_mapdict旧类别 ID 到新类别 ID 的映射提示路径处理统一用 pathlib避免 os.path 拼接在 Windows 和 Linux 下分隔符不一致导致的跨平台报错。4. 掌握三种改写场景类别映射、过滤、坐标缩放4.1 类别映射单把开源数据集的类别换成你的IDkitti 标注转 yolo、从 coco 数据集中抽取子类别本质都是同一件事源数据集的类别编号和你的类别表不一致。源数据集的 2 是 car你的 2 是 person直接训练会学到完全错误的对应关系。第一步是整理完整映射表把旧 ID 与你的新 ID 一一对应同时决定未映射类别怎么处理。def map_class(old_cls, cls_map, on_missingdrop): if old_cls in cls_map: return cls_map[old_cls] if on_missing drop: return None if on_missing keep: return old_cls raise ValueError(f类别 {old_cls} 没有映射) if __name__ __main__: mapping {0: 1, 1: 0, 2: 2} print(map_class(0, mapping)) # 1 print(map_class(5, mapping)) # None默认丢弃on_missing三个取值对应三种策略drop丢弃该目标keep保留原 ID 并作为新类别写入error抛异常中断适合在建映射表早期排查未知类别。写映射表之前先在整个数据集上跑一遍类别统计把实际出现的 ID 全部列出来再对照源数据集的类别名逐项确认避免漏掉出现频率很低的目标。4.2 过滤掉不想要的类别生成干净子集过滤场景通常出现在从大规模数据集中抽取子集的时候。比如只要车和行人其他类别全部丢弃。实现上比映射简单但有一个容易被忽略的环节标签过滤后对应的图片文件也要同步拷贝否则训练时 YOLO 会因为图片没有标签而直接跳过数据集规模对不上。import shutil from pathlib import Path def filter_labels(src_label_dir, dst_label_dir, dst_image_dir, img_dir, keep_classes): src Path(src_label_dir) dst_label Path(dst_label_dir) dst_image Path(dst_image_dir) dst_label.mkdir(parentsTrue, exist_okTrue) dst_image.mkdir(parentsTrue, exist_okTrue) for txt in src.rglob(*.txt): keep_lines [] with open(txt, r, encodingutf-8) as f: for line in f: parts line.strip().split() if not parts: continue if int(parts[0]) in keep_classes: keep_lines.append(line.strip()) if keep_lines: dst_txt dst_label / txt.name dst_txt.write_text(\n.join(keep_lines) \n, encodingutf-8) src_img Path(img_dir) / f{txt.stem}.jpg if src_img.exists(): shutil.copy2(src_img, dst_image / src_img.name)一个目标类别被过滤后图片里可能还有其他保留类别此时图片需要保留如果所有目标都被过滤最好连图片也一起丢弃而不是保留一张没有标签的图片。keep_lines为空时完全不写文件就是这个作用的具体实现。4.3 坐标缩放与偏移中心点微调与边距收缩坐标变换中做得最多的是两类一类是把框整体缩小一点让模型学得更贴合目标边缘另一类是按固定比例偏移中心点相当于给标签做一次离线数据增强。由于 YOLO 坐标已经归一化缩放和偏移都以图片宽高比例为基准与具体分辨率无关。参数含义示例值scale_w框宽缩放比例0.9scale_h框高缩放比例0.9offset_x中心点 x 方向偏移0.02offset_y中心点 y 方向偏移-0.01clip越界时是否截断到 [0,1]Truedef transform_coords(cls, x, y, w, h, scale_w1.0, scale_h1.0, offset_x0.0, offset_y0.0, clipTrue): new_w max(w * scale_w, 0.01) new_h max(h * scale_h, 0.01) new_x x offset_x new_y y offset_y if clip: new_x min(max(new_x, 0.0), 1.0) new_y min(max(new_y, 0.0), 1.0) new_w min(max(new_w, 0.0), 1.0) new_h min(max(new_h, 0.0), 1.0) return cls, new_x, new_y, new_w, new_hmax(w * scale_w, 0.01)限定框宽最小为 0.01防止缩放后等于 0 导致标签失效clipTrue时越界值截断到 [0,1]。注意中心点坐标截断后框仍可能部分超出图片严谨的做法是检查裁剪后框的面积占比过小的目标直接丢弃避免训练时出现大量退化样本。4.4 改写完怎么检查边界、空文件、重复ID批量跑完之前最后加一层输出校验。常见问题有三个坐标越界、空标签文件、映射后产生重复 ID。重复 ID 是指两个不同旧类别映射到同一个新类别这本身可能是有意的类别合并也可能是一张映射表写错了需要人工确认脚本无法替你判断语义。def validate_label_file(txt_path, num_classes): errs [] with open(txt_path, r, encodingutf-8) as f: for line_no, line in enumerate(f, 1): parts line.strip().split() if len(parts) ! 5: errs.append(f{line_no}: 字段数不对: {line}) continue cls int(parts[0]) if cls 0 or cls num_classes: errs.append(f{line_no}: 类别越界: {cls}) return errsnum_classes传入项目总类别数校验类别 ID 是否落在合法区间字段数不对的行单独上报。空文件检查放在外层遍历输出目录统计每个 txt 的行数原本有目标、改完变成 0 行的文件大概率是映射表漏项或过滤逻辑误删。发现问题后直接用 diff 对比原始文件和输出文件的对应行定位是哪一步解析出错。5. 最后把脚本打磨成命令行工具并做回归验证5.1 用argparse参数化一个脚本切换所有功能前面几节的函数如果直接堆在一起每换一个数据集就要改一次代码。常见做法是统一到一个 main.py 里用 argparse 接收任务类型和参数再加一个 --dry-run 参数让它只打印处理计划而不实际写文件。import argparse if __name__ __main__: parser argparse.ArgumentParser(descriptionYOLO标签改写工具) parser.add_argument(--task, choices[map, filter, transform], requiredTrue) parser.add_argument(--src, requiredTrue, help标签输入目录) parser.add_argument(--dst, requiredTrue, help标签输出目录) parser.add_argument(--map, nargs, typeint, help旧ID和新ID成对出现: --map 2 5 3 1) parser.add_argument(--keep, nargs, typeint, help要保留的类别ID列表) parser.add_argument(--dry-run, actionstore_true, help只打印处理计划) args parser.parse_args()--map 2 5 3 1表示“类别 2 改成 5类别 3 改成 1”nargs接收不定长参数代码里两两配对成 dict。--dry-run是批量处理前的保险几百个文件先跑一遍 dry-run检查输出文件数和类别统计确认无误再去掉该参数执行正式改写避免一次误操作毁掉整个数据集的标注。5.2 用类别分布统计验证改写没有跑偏回归验证最直接的办法是统计每个类别在改写前后的框数量。映射操作后总框数应保持一致过滤操作后总框数等于保留类别的原数量之和。这个数字对得上基本能确认解析逻辑没有误伤坐标。def count_classes(label_root): counter {} for txt in Path(label_root).rglob(*.txt): with open(txt, r, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) 5: continue cls int(parts[0]) counter[cls] counter.get(cls, 0) 1 return counter before count_classes(labels/train) after count_classes(labels_train_r1) print(改写前:, before) print(改写后:, after)改写前看到{2: 120, 3: 45}改写后看到{5: 120, 3: 45}说明映射正确如果变成{5: 119}说明有一行因解析失败被过滤回头定位那个 txt 文件的原始格式。跑完这步再开始训练。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进