Labelme与YOLO标注格式互转:原理、实现与避坑指南

发布时间:2026/10/21 12:17:21
Labelme与YOLO标注格式互转:原理、实现与避坑指南
1. 为什么标注格式转换是个绕不开的坎做过目标检测项目的人都有一个共同体会数据标注这件事从选择工具的那一刻起就注定要跟格式转换打交道。你不可能要求所有标注人员都用同一个工具也不可能保证团队里每个人对标注规范的理解决完全一致。更现实的情况是有人习惯用Labelme画多边形有人偏爱LabelImg拉矩形框而训练时用的YOLO系列又只认自己那套txt格式。这中间的鸿沟就得靠转换脚本来填。Labelme和YOLO的标注格式差异本质上源于两者对“目标”的建模方式不同。Labelme是通用型标注工具它的JSON输出保留了完整的图像信息、多边形顶点坐标、标签名称、甚至标注者的操作记录。而YOLO的txt格式极其精简每行只包含类别索引和归一化后的中心点坐标加宽高。一个追求信息完整一个追求训练效率没有谁好谁坏但转换时必须做取舍。这篇文章要聊的就是怎么在labelme2yolo和yolo2labelme这两个方向上把转换做扎实。我会从格式原理讲起把坐标变换的数学逻辑拆开揉碎然后给出可以直接跑的完整脚本最后把我在实际项目中踩过的坑和盘托出。不管你是刚接触目标检测的新手还是已经跑过几个项目的老手这些内容应该都能帮你省下不少调试时间。2. 两种格式的底层逻辑拆解2.1 Labelme的JSON结构到底存了什么Labelme标注一张图后会生成一个同名的JSON文件。这个文件的结构看起来复杂但核心信息就那么几项。我拿一个实际标注过的文件举例关键字段包括imagePath记录原始图片路径imageWidth和imageHeight保存图像尺寸shapes数组里存放所有标注对象。每个shape对象有label类别名、points多边形顶点坐标数组、shape_type标注类型矩形或多边形、group_id等字段。矩形标注在Labelme里其实是用两个点表示的左上角和右下角。但Labelme不直接存xmin、ymin、xmax、ymax而是存成[[x1,y1],[x2,y2]]这样的点对。多边形标注则是任意数量的顶点按顺序连成闭合区域。这里有个细节容易被忽略Labelme的坐标是绝对像素值原点在图像左上角x向右递增y向下递增。这个坐标系跟大多数图像处理库一致但跟YOLO需要的归一化坐标之间差了一步换算。还有一个坑Labelme的JSON里可能包含imageData字段这是图像的base64编码。如果标注时勾选了“保存图像数据”JSON文件会变得巨大无比转换时完全不需要这个字段读取时最好直接忽略否则内存吃不消。2.2 YOLO的txt格式为什么这么设计YOLO的标注文件是每张图对应一个txt文件名与图片名相同内容每行代表一个目标。每行的格式是类别索引 中心x 中心y 宽度 高度。这五个值用空格分隔后四个都是相对于图像宽高的归一化值范围在0到1之间。类别索引从0开始对应训练时names列表里的顺序。这种设计的精妙之处在于归一化坐标让模型不受输入图像分辨率的影响中心点加宽高的表示方式让回归任务更稳定。但代价是丢失了多边形的形状信息。如果你的Labelme标注是多边形转成YOLO时只能取外接矩形这是信息的有损压缩。所以做转换之前先想清楚你的任务到底需不需要保留多边形如果是实例分割那YOLO的txt格式根本不够用得用YOLO的seg格式那是另一套逻辑。另外YOLO的类别索引是整数而Labelme的label是字符串。转换时必须维护一个类别映射表确保所有标注文件里的类别名都能对应到唯一的索引。这个映射表一旦确定训练和推理都要保持一致否则会出现“训练时是猫推理时变成狗”的荒唐事。2.3 坐标变换的数学推导从Labelme的绝对坐标到YOLO的归一化坐标核心就是四步计算。假设矩形标注的两个点是(x1, y1)和(x2, y2)图像宽高分别是W和H。第一步确定矩形的实际边界。因为标注时可能从右下往左上画所以需要取xmin min(x1, x2)xmax max(x1, x2)y方向同理。第二步计算中心点cx (xmin xmax) / 2cy (ymin ymax) / 2。第三步计算宽高w xmax - xminh ymax - ymin。第四步归一化cx_norm cx / Wcy_norm cy / Hw_norm w / Wh_norm h / H。反向转换就是从归一化值还原绝对坐标cx cx_norm * Ww w_norm * W然后xmin cx - w/2xmax cx w/2y方向同理。看起来简单但实际写代码时浮点数精度、边界截断、图像尺寸读取错误这些问题都会让结果偏得离谱。我见过有人转换完发现框全跑到图像外面去了排查半天发现是imageWidth和imageHeight读反了。3. 动手实现labelme2yolo转换3.1 环境准备与依赖安装转换脚本用Python写最顺手依赖也就几个基础库。我习惯用虚拟环境隔离避免跟系统里的其他包打架。创建环境后安装这几个包numpy用于数值计算pillow用于读取图像尺寸有时候JSON里的尺寸不准得从原图重新读tqdm用于显示进度条pyyaml用于生成YOLO训练需要的配置文件。pip install numpy pillow tqdm pyyaml不需要装labelme本身我们只是解析它的JSON输出不调用它的标注界面。这样脚本可以在没有图形界面的服务器上跑批量处理几万张标注也不在话下。3.2 目录结构规划与类别映射在写代码之前先把目录结构定好。我推荐这样的布局原始Labelme标注放在labelme_annotations目录下图片放在images目录下转换后的YOLO格式输出到yolo_dataset目录。yolo_dataset里面再分images和labels两个子目录分别存放图片和txt标注。这种结构跟YOLO训练时的默认读取方式一致省得后面再调整。类别映射是转换的第一步。扫描所有JSON文件收集所有出现过的label值去重后排序生成一个从类别名到索引的字典。这个字典要保存成classes.txt或data.yaml训练时直接引用。这里有个经验类别顺序一旦确定就不要改否则之前训练的权重全部作废。如果后续新增了类别只能追加到列表末尾不能插在中间。import os import json from pathlib import Path def collect_classes(json_dir): classes set() for json_file in Path(json_dir).glob(*.json): with open(json_file, r, encodingutf-8) as f: data json.load(f) for shape in data[shapes]: classes.add(shape[label]) return sorted(list(classes))这段代码跑完classes列表就是所有类别名。排序是为了保证每次运行结果一致避免因为文件遍历顺序不同导致类别索引漂移。3.3 核心转换逻辑逐行解析转换的主函数要处理每个JSON文件提取图像尺寸遍历所有shape计算YOLO格式的五个值写入对应的txt文件。下面是我在实际项目中反复打磨过的代码加了详细的注释和异常处理。import json import os from pathlib import Path from PIL import Image from tqdm import tqdm def labelme_to_yolo(json_dir, image_dir, output_dir, classes): class_to_idx {name: idx for idx, name in enumerate(classes)} label_out_dir Path(output_dir) / labels image_out_dir Path(output_dir) / images label_out_dir.mkdir(parentsTrue, exist_okTrue) image_out_dir.mkdir(parentsTrue, exist_okTrue) json_files list(Path(json_dir).glob(*.json)) for json_file in tqdm(json_files, descConverting): with open(json_file, r, encodingutf-8) as f: data json.load(f) img_w data.get(imageWidth) img_h data.get(imageHeight) # 如果JSON里没有尺寸信息从原图读取 if not img_w or not img_h: img_path Path(image_dir) / data[imagePath] with Image.open(img_path) as img: img_w, img_h img.size lines [] for shape in data[shapes]: label shape[label] if label not in class_to_idx: continue points shape[points] # 计算外接矩形 xs [p[0] for p in points] ys [p[1] for p in points] xmin, xmax min(xs), max(xs) ymin, ymax min(ys), max(ys) # 边界截断防止标注超出图像范围 xmin max(0, min(xmin, img_w)) xmax max(0, min(xmax, img_w)) ymin max(0, min(ymin, img_h)) ymax max(0, min(ymax, img_h)) # 跳过无效框 if xmax xmin or ymax ymin: continue # 计算归一化值 cx (xmin xmax) / 2.0 / img_w cy (ymin ymax) / 2.0 / img_h w (xmax - xmin) / img_w h (ymax - ymin) / img_h # 再次截断确保在0-1之间 cx max(0, min(cx, 1)) cy max(0, min(cy, 1)) w max(0, min(w, 1)) h max(0, min(h, 1)) idx class_to_idx[label] lines.append(f{idx} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) # 写入txt文件 txt_name json_file.stem .txt with open(label_out_dir / txt_name, w) as f: f.write(\n.join(lines)) # 复制图片到输出目录 src_img Path(image_dir) / data[imagePath] if src_img.exists(): import shutil shutil.copy(src_img, image_out_dir / src_img.name)这段代码有几个关键点值得展开说。第一imageWidth和imageHeight不一定可靠有些标注工具在图片旋转后不会更新这两个值所以从原图重新读取是更稳妥的做法。第二边界截断很重要Labelme允许标注点稍微超出图像边界但YOLO训练时超出范围的坐标会导致损失计算异常。第三浮点数格式化保留六位小数足够精确又不会让文件过大。3.4 生成YOLO训练所需的配置文件转换完标注还得生成YOLO训练用的data.yaml。这个文件告诉训练框架去哪里找图片、类别名是什么、类别数是多少。import yaml def generate_yaml(output_dir, classes): data { path: str(Path(output_dir).absolute()), train: images, val: images, nc: len(classes), names: classes } with open(Path(output_dir) / data.yaml, w) as f: yaml.dump(data, f, default_flow_styleFalse, allow_unicodeTrue)实际项目中训练集和验证集要分开train和val指向不同的图片列表文件。这里为了演示简化了真正用的时候记得把数据集划分好。4. 反向操作yolo2labelme转换4.1 什么场景需要往回转有人可能觉得奇怪既然都用YOLO训练了为什么还要转回Labelme实际工作中这种需求不少见。比如你用一个预训练模型自动标注了一批数据想人工复核和修正这时候把YOLO的txt转成Labelme的JSON标注人员就能在熟悉的界面里拖拽调整。再比如你需要把YOLO格式的数据交给第三方团队但他们只用Labelme那就得转回去。还有一种情况是模型推理结果的可视化。YOLO推理出来的是归一化坐标要画到原图上展示转成Labelme的绝对坐标会更直观。虽然直接用OpenCV画框也行但生成JSON后可以用Labelme打开检查比写脚本画图灵活得多。4.2 从txt还原JSON的完整实现反向转换需要知道图像尺寸因为YOLO的归一化坐标必须乘以宽高才能还原成像素坐标。所以这个方向的转换必须同时读取图片文件。另外Labelme的JSON有一些固定字段比如version、flags、imageData等即使为空也要写上否则Labelme打开时可能报错。import json import os from pathlib import Path from PIL import Image from tqdm import tqdm def yolo_to_labelme(txt_dir, image_dir, output_dir, classes): output_dir Path(output_dir) output_dir.mkdir(parentsTrue, exist_okTrue) txt_files list(Path(txt_dir).glob(*.txt)) for txt_file in tqdm(txt_files, descConverting): # 找到对应的图片 img_name None for ext in [.jpg, .jpeg, .png, .bmp]: candidate Path(image_dir) / (txt_file.stem ext) if candidate.exists(): img_name candidate.name img_path candidate break if not img_name: print(fWarning: no image found for {txt_file.name}) continue with Image.open(img_path) as img: img_w, img_h img.size shapes [] with open(txt_file, r) as f: for line in f: parts line.strip().split() if len(parts) ! 5: continue idx int(parts[0]) cx, cy, w, h map(float, parts[1:]) # 还原绝对坐标 abs_cx cx * img_w abs_cy cy * img_h abs_w w * img_w abs_h h * img_h xmin abs_cx - abs_w / 2 ymin abs_cy - abs_h / 2 xmax abs_cx abs_w / 2 ymax abs_cy abs_h / 2 # 截断到图像边界 xmin max(0, min(xmin, img_w)) ymin max(0, min(ymin, img_h)) xmax max(0, min(xmax, img_w)) ymax max(0, min(ymax, img_h)) label classes[idx] if idx len(classes) else fclass_{idx} shape { label: label, points: [[xmin, ymin], [xmax, ymax]], group_id: None, shape_type: rectangle, flags: {} } shapes.append(shape) json_data { version: 5.0.1, flags: {}, shapes: shapes, imagePath: img_name, imageData: None, imageHeight: img_h, imageWidth: img_w } with open(output_dir / (txt_file.stem .json), w, encodingutf-8) as f: json.dump(json_data, f, ensure_asciiFalse, indent2)这段代码里imageData设为None是关键。如果设成实际的base64字符串JSON文件会膨胀到几MB一张图完全没必要。Labelme打开时如果没有imageData会自动从imagePath加载图片效果一样。4.3 多边形标注的降级处理前面提到YOLO的txt格式只存矩形框所以从YOLO转回Labelme时只能生成矩形标注无法还原多边形。如果你的原始数据是多边形转成YOLO再转回来形状信息就永久丢失了。这是格式本身的限制没有绕过的方法。如果确实需要保留多边形唯一的办法是在转换时额外保存一份多边形信息。比如在YOLO的txt旁边再存一个_polygon.json文件记录每个目标的原始顶点。这样即使经过YOLO训练原始标注仍然可追溯。这个做法在数据管理严格的团队里很常见虽然增加了存储开销但避免了数据资产的不可逆损失。5. 实操中踩过的坑与排查技巧5.1 坐标越界与负值问题转换后最常见的报错就是坐标越界。YOLO训练时如果遇到cx或cy不在0到1之间或者w、h为负数损失函数会直接爆炸。我遇到过好几次排查下来原因无非这么几个标注时点拖到了图像外面、JSON里的图像尺寸跟实际图片不一致、多边形顶点顺序混乱导致外接矩形计算错误。解决办法就是在转换脚本里加双重保险。第一层是计算完归一化坐标后强制截断到0到1之间。第二层是在写入txt之前检查w和h是否大于0小于等于0的直接跳过。这两层过滤能挡掉99%的异常数据。剩下的1%可能是标注本身就有问题需要人工复核。提示截断操作会改变框的实际位置和大小如果大量标注都需要截断说明标注质量有问题应该回头修正标注而不是依赖脚本兜底。5.2 类别名大小写与空格陷阱Labelme的类别名是字符串用户输入时可能带空格、大小写混用、甚至中英文混排。比如“person”、“Person”、“ person”在Labelme里是三个不同的类别但转成YOLO时如果映射表没处理好可能被合并成一个也可能被拆成三个。我建议在收集类别时统一做一次清洗去除首尾空格统一转成小写把连续空格替换成下划线。这样能避免很多莫名其妙的类别混乱。清洗之后还要检查是否有类别名清洗后重复的情况。比如“Traffic Light”和“traffic_light”清洗后可能变成同一个这时候需要人工决定是合并还是保留区分。我的经验是除非业务上确实需要区分否则合并更利于模型学习。5.3 图像尺寸读取失败的兜底方案有些图片的EXIF信息里带有旋转标记PIL读取时默认不应用旋转导致img.size返回的宽高跟实际显示的不一致。这种情况下JSON里的imageWidth和imageHeight可能反而是对的。所以我的策略是优先用JSON里的尺寸如果JSON里没有或者明显异常比如宽高为0再从图片读取。如果两边都拿不到就跳过这个文件并记录日志。还有一种情况是图片文件损坏PIL直接抛异常。这时候用try-except包住把损坏的文件路径写到一个error.log里转换完成后统一处理。批量处理几万张图时总会有几张有问题的不能让整个脚本崩掉。5.4 常见问题速查表问题现象可能原因排查方法解决方案训练时loss为nan坐标越界或宽高为负检查txt文件是否有超出0-1的值转换时加截断和有效性检查类别索引错乱类别映射表不一致对比训练配置和转换时的classes列表固定类别顺序保存映射文件框位置偏移图像尺寸读取错误对比JSON和实际图片的宽高优先从原图读取尺寸转换后图片缺失图片路径不匹配检查JSON里的imagePath字段统一图片命名和目录结构Labelme打开报错JSON字段缺失检查version、flags等字段补全所有必需字段多边形变矩形格式本身限制确认原始标注类型额外保存多边形信息6. 批量处理与自动化流水线6.1 多目录递归扫描实际项目的数据往往分散在多个子目录里按日期、按批次、按标注人员分文件夹存放。写转换脚本时用Path.rglob(*.json)代替glob就能递归扫描所有子目录。输出时保持相同的相对路径结构方便追溯每个文件来自哪里。def batch_convert(root_dir, output_dir, classes): root Path(root_dir) for json_file in root.rglob(*.json): rel_path json_file.relative_to(root) out_path Path(output_dir) / rel_path.with_suffix(.txt) out_path.parent.mkdir(parentsTrue, exist_okTrue) # 调用单文件转换函数 convert_single(json_file, out_path, classes)这种结构下图片的复制也要保持相对路径否则YOLO训练时按文件名匹配图片和标注会找不到。6.2 增量转换与断点续传数据标注是个持续的过程今天标了100张明天又标了50张。每次都全量转换太浪费时间最好能只转换新增的文件。实现方式很简单在输出目录里检查对应的txt文件是否已存在如果存在且修改时间晚于JSON文件就跳过。这样第二次运行脚本时只处理新增和修改过的标注。def needs_conversion(json_path, txt_path): if not txt_path.exists(): return True return json_path.stat().st_mtime txt_path.stat().st_mtime这个逻辑虽然简单但在实际项目中能省下大量时间。尤其是数据量到几万张以后全量转换可能要跑十几分钟增量转换几秒钟就完事了。6.3 转换结果的质量校验转换完成后别急着开始训练先做一轮质量校验。我通常写一个独立的校验脚本做这几件事检查每个txt文件的类别索引是否在有效范围内、检查坐标是否都在0到1之间、检查每张图片是否都有对应的txt文件、统计每个类别的样本数量是否均衡。def validate_dataset(label_dir, num_classes): issues [] for txt_file in Path(label_dir).glob(*.txt): with open(txt_file) as f: for line_num, line in enumerate(f, 1): parts line.strip().split() if len(parts) ! 5: issues.append(f{txt_file}:{line_num} 格式错误) continue idx int(parts[0]) if idx 0 or idx num_classes: issues.append(f{txt_file}:{line_num} 类别索引越界) coords list(map(float, parts[1:])) if any(c 0 or c 1 for c in coords): issues.append(f{txt_file}:{line_num} 坐标越界) return issues这个校验脚本跑一遍能把大部分数据问题提前暴露出来。等到训练时才发现问题排查成本会高很多。7. 一些提高效率的实用技巧7.1 用并行处理加速大批量转换Python的multiprocessing模块可以轻松把转换任务分配到多个CPU核心上。对于几万张标注的转换单进程可能要跑十几分钟用8个进程并行处理两分钟就能搞定。关键是把文件列表切分成多个块每个进程处理一块最后合并结果。from multiprocessing import Pool def parallel_convert(json_files, classes, output_dir, num_workers8): with Pool(num_workers) as pool: args [(jf, classes, output_dir) for jf in json_files] pool.starmap(convert_single_wrapper, args)注意并行处理时写入同一个输出目录不会有冲突因为每个文件写的是不同的txt。但如果要生成全局的类别统计就需要等所有进程结束后再汇总。7.2 转换日志与可追溯性每次转换都生成一份日志记录转换了多少文件、跳过了多少、遇到了哪些异常。这份日志在出问题时是救命稻草。我习惯在日志里记录每个文件的转换结果包括原始路径、输出路径、标注数量、是否有异常。这样即使过了几个月回头查数据也能快速定位。日志格式用JSON Lines最方便每行一个JSON对象既人类可读又机器可解析。用logging模块配置输出到文件同时打印到控制台方便实时观察进度。7.3 与训练框架的对接要点转换完的数据要能直接被训练框架读取才算真正完成。YOLO系列训练时data.yaml里的train和val路径要指向包含图片的目录框架会自动在同级目录下找labels文件夹里的txt。所以输出目录的结构必须是images和labels平级不能嵌套太深。另外图片和txt的文件名必须严格对应除了扩展名不同其他部分完全一致。如果原始图片名带空格或特殊字符最好在转换时统一重命名避免训练时路径解析出错。我一般用uuid或哈希值重命名虽然可读性差了点但胜在稳定可靠。8. 格式转换之外的一些思考标注格式转换看起来是个纯技术活但做多了会发现它其实反映了数据管理的一些基本原则。第一原始数据要保留转换后的数据是派生品任何时候都能从原始数据重新生成。第二转换过程要可复现同样的输入必须得到同样的输出不能依赖随机数或系统时间。第三转换逻辑要版本化脚本改了要记录改了什么、为什么改否则过段时间自己都忘了。我在实际项目中的体会是花在格式转换和校验上的时间最终都会在训练阶段省回来。数据干净训练就顺数据有问题调参调到天亮也白搭。所以别嫌转换脚本麻烦把它写扎实了后面会感谢自己的。最后分享一个小技巧如果你经常需要在两种格式之间来回转可以写一个统一的命令行工具用argparse接收方向参数和目录路径一条命令完成转换。再配合watchdog监听标注目录的变化一旦有新文件就自动转换整个流水线就完全自动化了。这个工具我用了两年多从没在格式问题上再浪费过时间。