Labelme JSON批量转YOLO/COCO/Mask:完整转换脚本与排坑指南

发布时间:2026/10/14 11:17:44
Labelme JSON批量转YOLO/COCO/Mask:完整转换脚本与排坑指南
做数据准备的时候最烦的往往不是标注本身而是标注完之后那一堆JSON文件没法直接喂给训练脚本。我用labelme做图像标注也有一段日子了从目标检测到实例分割都跑过每次换一个任务就要重新写一遍转换脚本踩过的坑说多不多但每次都相似。这篇文章就把我常用的batch转换思路和代码整理出来重点解决“labelme标注出的JSON如何批量转成YOLO、COCO、语义分割Mask”这个核心问题顺带把JSON结构、坐标换算、坑点排查这些容易卡住人的细节一起讲清楚。如果你是刚接触labelme、正准备自建数据集做目标检测或者分割任务这篇文章可以直接照着抄。如果你已经写过转换脚本但经常遇到类别错乱、坐标越界、中文路径报错这类问题也能从后面的问题排查部分找到对应解法。1. labelme的JSON结构拆解能批量转换的前提很多人写转换脚本失败不是代码能力不行而是没搞明白labelme的JSON里到底存了什么。我见过不少朋友一上来就拿着JSON瞎猜字段猜错了就报KeyError然后到处问为什么。其实labelme的JSON结构非常固定把字段含义搞清楚后面的转换就是纯粹的数学计算。1.1 一个标注文件里到底存了哪些信息用labelme标注一张图并保存后生成的是一个和图片同名的JSON文件。剥开来看里面主要有这些键字段类型含义versionstrlabelme的版本号转换时不需要关心flagsdict全局标志一般标注时不用写脚本时可以忽略shapeslist核心字段所有标注对象都放在这个列表里imagePathstr原图文件名注意它可能只存文件名也可能带相对路径imageDatastr原图数据的base64编码可能是空值imageHeightint原图高度像素单位imageWidthint原图宽度像素单位shapes里的每一个元素是一个字典对应一个标注对象结构长这样{ label: cat, points: [[x1, y1], [x2, y2], ...], group_id: null, description: , shape_type: rectangle, flags: {} }label是类别名points是坐标点列表shape_type表示标注类型。理解这四者的关系是批量转换的第一步。这里有一个值得注意的地方imageData这个字段。labelme保存的时候默认会把图片压缩成base64字符串塞进JSON里所以JSON文件体积会明显变大。如果原图还在转换脚本其实用不到imageData只需要imageWidth和imageHeight来做坐标归一化。但如果原图丢失了imageData可以用来把图片解出来后面我会提到这种做法。1.2 shape_type不同后续处理完全不同labelme支持多种标注形状最常用的是rectangle和polygon偶尔会用到circle、linestrip、point。不同shape_type对坐标的解释方式完全不同这是新手最容易翻车的点。rectangle的points固定有两个点分别是矩形的一个对角和另一个对角但注意labelme并不会保证先点的是左上角所以两个点的坐标大小关系是不确定的。要得到一个规范的[x_min, y_min, x_max, y_max]必须用min和max分别处理。polygon的points是一串点最少三个按多边形边界的顺序排列。如果只是转成目标检测的水平框bbox就需要从这些点中算出一个最小外接矩形也就是所有点的横坐标最小值和最大值、纵坐标最小值和最大值。但如果做实例分割或语义分割就不应该只取bbox而要把完整的点列保留下来后续转COCO或生成Mask时都需要用到完整多边形。circle的points也有两个点第一个是圆心第二个是圆上一点半径是两个点之间的距离。linestrip和point用得少建议在标注阶段就约定好除非有特殊需求否则统一用rectangle和polygon这样转换脚本写起来干净很多。2. 批量转换的整体设计先想清楚再动手如果你想写一个“万能”转换脚本那我劝你趁早放弃这个念头。不同训练框架对标注格式的要求差异非常大转换脚本必须跟着目标格式走。动手写码之前先花五分钟想清楚三件事最终格式是什么、源文件怎么组织、类别表怎么定义。想清楚了代码就是体力活。2.1 目标格式决定转换路径我目前遇到的需求主要集中在三种YOLO格式、COCO格式、单通道Mask图。三者对应的转换思路完全不同。YOLO格式是目标检测最常用的输入格式每个图片对应一个同名txt文件每行一个目标内容是“类别序号 归一化中心点x 归一化中心点y 归一化宽度 归一化高度”。它只关心包围框不关心多边形细节所以标注时用矩形还是多边形都无所谓脚本里统一转成bbox即可。COCO格式是一个大JSON文件包含images、annotations、categories三个主要部分其中annotations里的segmentation字段需要的是多边形点列拍平成一维列表同时也要求提供bbox和area。这种格式最适合实例分割任务但对字段完整性要求高少一个键都可能影响训练脚本解析。Mask图格式是语义分割任务常用的方式每个图片对应一张单通道PNG像素值就是类别索引比如0是背景、1是第一个类别、2是第二个类别。转换时需要用多边形填充算法把每个标注对象画到全零矩阵上。还有一类需求是转成PASCAL VOC的XML格式思路和YOLO类似只是把归一化坐标换回像素坐标再包一层XML结构。如果理解了JSON解析和坐标换算VOC格式也就是换个模板输出的事。2.2 转换流程的通用骨架不管目标格式是什么批量转换的骨架都一样遍历指定目录下所有JSON文件读取每个JSON取出imageWidth、imageHeight、shapes遍历shapes提取类别名和坐标点根据目标格式要求对坐标做换算写入目标文件txt、json或png抽样可视化验证这套流程看起来简单但每一步都有隐藏细节。比如遍历目录时要不要递归子目录JSON文件名和图片文件名是否一致类别名如何映射成数字这些细节如果不提前定好脚本运行到一半就会出各种幺蛾子。我建议转换脚本写成一个函数一次处理一个JSON外层再用循环批量调用。这样单个文件出错时容易定位也方便后续用多进程加速。2.3 文件组织与命名约定labelme默认把JSON和原图放在同一目录下而且默认使用“原图名.json”这种命名方式。转换脚本最好以JSON文件为准因为有些场景下图片可能被移动过或JSON是用imageData里内嵌的图数据生成的导致imagePath指向的文件根本不存在。我的习惯是在项目根目录下建三个子目录annotations放原始JSONimages放原图converted放转换后的结果。标注完第一张图后就把目录结构固定下来后续批量生产时不会乱。另外强烈建议在转换之前确认一遍所有JSON里的类别集合。做法很简单先遍历一遍全部JSON把出现过的label收集到一个Set里再排序后生成类别与索引的映射表。不要边遍历边动态生成映射否则同一个类别在不同文件里可能对应不同索引前面写出的文件就全错了。这一点我后面还会再展开。3. 核心实现labelme JSON转YOLO格式YOLO格式是我最常用的目标格式没有之一。很多做检测任务的朋友标注完第一件事就是转YOLO所以我先写这个完整实现把坐标换算逻辑讲透。3.1 YOLO格式对坐标有什么要求一个YOLO格式的txt文件每一行代表一个目标五个数字用空格分隔class_id x_center y_center width height其中x_center、y_center、width、height全部是归一化数值范围在0到1之间计算方式是用像素值除以图片的宽或高。下面这种行就是一个例子2 0.515625 0.458333 0.187500 0.291667这个例子表示类别ID是2bbox中心点位于图片的51.56%宽度和45.83%高度处宽是图片的18.75%高是图片的29.17%。之所以要归一化是因为YOLO训练时会对输入图片做resize保持归一化坐标可以避免缩放带来的偏差。这也意味着转换脚本必须知道当前图片的原始宽高好在labelme的JSON里已经存了imageWidth和imageHeight不需要额外读图片。3.2 矩形框和多边形框的坐标换算矩形标注的坐标换算比较简单但要注意两个点不一定是左上角和右下角。假设JSON里points是[[x1, y1], [x2, y2]]先做一步排序x_min min(x1, x2) x_max max(x1, x2) y_min min(y1, y2) y_max max(y1, y2)然后转成YOLO中心点格式x_center (x_min x_max) / 2 / img_width y_center (y_min y_max) / 2 / img_height width (x_max - x_min) / img_width height (y_max - y_min) / img_height这里的除法用浮点运算不要用整数除法Python 3里/默认就是浮点除法没问题。如果是多边形标注比如shape_type polygon而你又只需要检测框就先从全部顶点中取最小外接矩形xs [p[0] for p in points] ys [p[1] for p in points] x_min, x_max min(xs), max(xs) y_min, y_max min(ys), max(ys)再套上面的公式。这里要特别提醒如果多边形是一个倾斜的物体最小外接矩形axis-aligned bbox会有一定的背景噪声但YOLO这类水平框检测器只能这么做没有别的选择。如果这个倾斜物体对你的任务很重要就要考虑用旋转框检测但labelme本身的坐标数据并不直接支持旋转框标注需要额外约定格式这里就不展开了。3.3 可直接运行的批量转换脚本下面这段脚本是我目前用得最顺手的版本支持递归遍历目录自动收集类别表输出YOLO格式的txt文件和类别名文件。直接把路径改成自己的就能跑。import json import os from pathlib import Path def convert_labelme_json_to_yolo(json_path, output_dir, class_map): 转换单个labelme JSON文件为YOLO txt json_path: labelme生成的json文件路径 output_dir: txt输出目录 class_map: dict类别名到索引的映射 with open(json_path, r, encodingutf-8) as f: data json.load(f) img_width data[imageWidth] img_height data[imageHeight] txt_name json_path.stem .txt lines [] for shape in data[shapes]: label shape[label] if label not in class_map: # 如果遇到未知类别跳过并提示 print(f[Warning] {json_path.name}: unknown label {label}) continue class_id class_map[label] points shape[points] if shape[shape_type] rectangle: x1, y1 points[0] x2, y2 points[1] else: # 多边形或多段线统一取最小外接矩形 xs [p[0] for p in points] ys [p[1] for p in points] x1, x2 min(xs), max(xs) y1, y2 min(ys), max(ys) # 防止坐标越界 x1 max(0, min(x1, img_width)) x2 max(0, min(x2, img_width)) y1 max(0, min(y1, img_height)) y2 max(0, min(y2, img_height)) if x2 x1 or y2 y1: print(f[Warning] {json_path.name}: invalid bbox after clip) continue x_center (x1 x2) / 2.0 / img_width y_center (y1 y2) / 2.0 / img_height box_width (x2 - x1) / img_width box_height (y2 - y1) / img_height # 归一化数值保留6位小数避免浮点尾巴 lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {box_width:.6f} {box_height:.6f}) if lines: output_txt Path(output_dir) / txt_name output_txt.write_text(\n.join(lines) \n, encodingutf-8) else: print(f[Info] {json_path.name}: no valid shapes, skip writing) def batch_convert(json_dir, output_dir): 批量转换指定目录下的所有labelme JSON json_dir Path(json_dir) output_dir Path(output_dir) output_dir.mkdir(parentsTrue, exist_okTrue) # 第一步遍历所有json收集全部类别 class_set set() json_files list(json_dir.rglob(*.json)) if not json_files: print(f[Error] no json files found in {json_dir}) return for json_file in json_files: with open(json_file, r, encodingutf-8) as f: data json.load(f) for shape in data[shapes]: class_set.add(shape[label]) class_map {name: idx for idx, name in enumerate(sorted(class_set))} print(f[Info] classes: {class_map}) # 写出类别映射文件后续训练时要保持一致 class_txt output_dir / classes.txt class_txt.write_text(\n.join(sorted(class_set)) \n, encodingutf-8) # 第二步逐个转换 for json_file in json_files: convert_labelme_json_to_yolo(json_file, output_dir, class_map) print(f[Done] {json_file.name}) if __name__ __main__: batch_convert(./annotations, ./yolo_labels)这段脚本里有几个细节值得注意。我用了Path.rglob(*.json)做递归遍历这样即使标注文件分散在子目录里也能全部找到。stem取的是文件名去掉后缀部分用来生成同名txt。sorted(class_set)是为了保证每次运行时类别顺序稳定避免因为JSON文件处理顺序不同导致索引漂移。3.4 类别索引稳定性的处理细节类别索引这个问题是批量转换中翻车率最高的地方。很多人一开始图省事在遍历JSON时动态给类别分配ID比如第一次遇到“cat”就分配0第二次遇到“dog”就分配1结果处理到第三个文件时又遇到“cat”动态分配的是2前面已经写好的txt文件里的cat就变成0了。数据集一乱训练出来的模型完全没法看。我这里的做法是先扫描一遍所有JSON收集完整的类别集合统一排序后再生成映射。这样无论处理顺序如何同一个类别始终对应同一个索引。还有一个小细节classes.txt里保存的类别顺序必须和映射表一致。后续用YOLO训练时这个文件里的每一行代表一个类别的名字顺序对应txt里的class_id。如果哪天你重新标注了一批数据类别集合变了就要重新生成classes.txt并且旧的标签索引已经失效需要重新转换。4. 进阶JSON转COCO与语义分割Mask如果只是做目标检测上面那段脚本已经够用。但做实例分割或语义分割时就需要更复杂的转换逻辑。这一节我讲两个进阶方向COCO格式和Mask图。4.1 转COCO的两种思路和完整实现COCO格式的核心是一个JSON字典最简结构如下{ images: [ {id: 0, file_name: 001.jpg, width: 800, height: 600} ], annotations: [ { id: 0, image_id: 0, category_id: 1, bbox: [x, y, w, h], area: 12345.0, segmentation: [[x1, y1, x2, y2, ...]], iscrowd: 0 } ], categories: [ {id: 1, name: cat} ] }其中bbox是像素坐标的[x_min, y_min, width, height]segmentation是拍平的多边形点列表area可以是多边形面积也可以近似用bbox面积。iscrowd一般写0表示这是独立目标而不是群体目标。一个比较省事的实现思路是这样的遍历所有JSON文件按顺序给每张图片分配image_id给每个标注对象分配annotation_id最后把所有信息组装到三个列表里再一次性dump成JSON。这里有一个我踩过的坑segmentation字段必须是整数列表但坐标值有小数时要先取整否则很多训练框架会报警。比较稳妥的做法是用round()四舍五入或者直接用int()截断。另外COCO的bbox要求宽和高为正数如果从标注数据中算出的width或height为0这个标注对象应该直接丢弃。转COCO的核心代码可以复用前面读取JSON和提取多边形的逻辑区别只在坐标不用归一化、bbox要用像素值、segmentation要保留完整点列。由于代码较长建议写一个独立的convert_to_coco.py把一个数据集的所有JSON一次性组装成单个annotations.json。4.2 转语义分割Mask的完整过程语义分割任务需要每个输入图片对应一张单通道标签图像素值就是类别索引。转换时用numpy和cv2最方便。基本流程如下import json import numpy as np import cv2 from pathlib import Path def draw_mask_from_json(json_path, output_dir, class_map, shape(600, 800)): with open(json_path, r, encodingutf-8) as f: data json.load(f) h, w data[imageHeight], data[imageWidth] mask np.zeros((h, w), dtypenp.uint8) for shape in data[shapes]: label shape[label] if label not in class_map: continue class_id class_map[label] points np.array(shape[points], dtypenp.int32) if shape[shape_type] polygon: cv2.fillPoly(mask, [points], colorclass_id) elif shape[shape_type] rectangle: x1, y1 points[0] x2, y2 points[1] x_min, x_max min(x1, x2), max(x1, x2) y_min, y_max min(y1, y2), max(y1, y2) mask[y_min:y_max, x_min:x_max] class_id else: print(f[Warning] unsupported shape_type: {shape[shape_type]}) out_path Path(output_dir) / (Path(json_path).stem .png) cv2.imwrite(str(out_path), mask)这段话里最关键的是两个地方。第一mask是uint8类型所以类别索引最大不能超过255否则会溢出。对于常规数据集来说这个限制基本用不到但如果你有超过200个类别要改用uint16类型同时保存时要选择支持16位的图像格式否则信息会丢失。第二cv2.fillPoly处理多边形边界时可能会有1到2像素的偏移这是正常现象。如果对边缘精度有极高要求可以做一下掩膜腐蚀或膨胀来矫正但一般分割任务不敏感。转角度的矩形要注意上面代码里rectangle分支直接用了切片赋值要求坐标都是整数并且x_min x_max、y_min y_max。如果标注时点到图片外面去了生成的切片会越界报错所以最好在赋值前做一次裁剪x_min max(x_min, 0) y_min max(y_min, 0) x_max min(x_max, w) y_max min(y_max, h)4.3 为什么效率提升的关键在批处理设计有人可能会问直接写一个脚本把JSON转成COCO和Mask不就行了吗为什么还要设计成批处理我的看法是批处理的核心优势在于数据集的可迭代性。今天你拿到1000张图的JSON明天可能又拿到500张后天可能发现某些标注错了要重新导出。如果脚本一次处理全部每次都要重新扫描、重新生成映射、重新写文件既慢又容易出错。更合适的做法是设计一个“增量友好的转换流程”每次新增数据时只对新JSON做转换类别映射文件单独维护新增分类时手工更新。虽然多写几行代码但长期维护成本低很多。我现在的做法是把上述两个转换函数都封装到一个工具类里命令行传入--json-dir和--output-format参数想转YOLO就转YOLO想转COCO就转COCO不用改代码。5. 常见问题与排查技巧实录写转换脚本的过程本质上是一个不断Debug的过程。我把自己遇到过的坑整理出来按照“症状-原因-解法”的方式列出来方便你对照排查。5.1 编码与路径问题最典型的错误是在Windows下运行脚本读取labelme生成的JSON报UnicodeDecodeError或者转换后中文类别名乱码。原因是labelme保存JSON时默认使用UTF-8编码而某些编辑器或系统默认的读取编码不是UTF-8。解决办法很简单所有读写操作都显式指定encodingutf-8包括open()、Path.write_text()。另一个常见问题是中文路径。如果你的项目目录或文件名包含中文在Python里一般不会有大问题但如果在YOLO训练时使用标点或空格某些框架可能会解析出错。我建议在项目开始前就把目录和文件名统一成英文小写加下划线比如images/train/001_cat.jpg这样可以避免后面所有环节的路径烦恼。5.2 坐标越界与顶点顺序问题坐标越界的现象是转换出来的bbox出现在图片外面或者训练时报警告说width或height为负数。原因是标注时不小心把点拖到了图片边界之外或者矩形两个对角点的顺序不是预期的左上右下。解决方法有两个层面。一个是在标注时约束自己尽量把点控制在图片范围内另一个是在转换脚本里做坐标裁剪确保归一化值落在0到1之间。我上面的YOLO脚本里已经写了x1 max(0, min(x1, img_width))这类裁剪逻辑就是为了防止这种情况让训练直接崩掉。关于顶点顺序再次强调labelme矩形标注的两个点只是任意对角点不是固定“先左上后右下”。无论什么时候都要用min/max处理。5.3 类别映射错误症状是训练出来的模型把猫识别成狗或者某个类别完全没有被模型学到。原因多半是类别索引不统一。我遇到过一种特别隐蔽的情况第一次转换时类别集合是[cat, dog]cat对应0、dog对应1。后来新增了一批bird的标注再次运行脚本时排序后变成了[bird, cat, dog]前两批的cat、dog索引全变了但很老练的训练脚本还在用旧的classes.txt结果全是错的。所以我现在坚持类别表一旦定义就不能随意变化。除非重新转换整个数据集否则新增类别时要把新索引排在现有类别的后面不要让排序改变已有类别的位置。最简单的方式是手工维护classes.txt脚本只负责读取这个文件而不自动生成。5.4 快速排查清单表症状可能原因快速解法转换脚本报KeyErrorshape里没有指定键先打印单个JSON的shapes字段确认labelme版本输出的txt没有任何内容类别映射中找不到对应label打印class_map检查类别名是否匹配bbox坐标超出1原图宽高读取异常确认JSON里imageWidth和imageHeight是正整数中文类别名乱码读写编码不一致所有IO统一encodingutf-8转换后图片与原标注不符用到imageData或路径不匹配确认转换时图像名和JSON名对应这里有一个特别容易被忽略的点标注时如果改变了原图的分辨率例如先标注了1000x800的图后来缩放成了800x640但JSON里的imageHeight和imageWidth没有更新转换出来的坐标就会偏。遇到这种情况最好的方式是重新打开旧JSON确认图像尺寸或者在转换前用图像库读一次原图尺寸以原图的实际尺寸为准。5.5 批量转换效率低时怎么办当JSON数量达到几千上万时逐文件解析确实会变慢。但大多数场景下瓶颈不在文件数量而在于频繁打开和关闭文件以及反复解析JSON。我的经验是先做一次小样本测试确认所有逻辑正确后再全量跑。全量跑时如果速度不够可以考虑用multiprocessing并行处理但要注意写入文件时的目录冲突以及类名映射需要提前计算好不能每个进程各自生成一份。如果只是偶尔转换一次完全没必要上并行。写一个进度条提示让它跑完就行了比自己手动调多线程省心得多。6. 写在最后的实操心得折腾labelme数据转换这么多次我最有感触的一点是标注格式转换本身不难难的是从一开始就定好规范。如果你现在还没开始标注我建议先定义好类别清单并同步检查JSON目录结构用英文命名文件和目录统一用矩形或多边形标注不混用。这些规则能让你后面的转换脚本少掉一半的bug。具体到我自己的习惯我会在新建数据集时先写下三行备忘类别清单一份、原图和标注文件命名规则一份、每个类别的颜色或索引定义一份。转换脚本写完后不要急着批量运行先挑三个不同情况的JSON文件做验证分别覆盖单目标、多目标、多边形标注三种场景确认输出正常后再全量跑。转换完成后抽几张图把bbox或者Mask画回原图上看看这一步虽然简单但比对着坐标数字检查高效得多。如果你只是临时转换一次把文中的脚本复制过去改改路径就能用。如果你准备长期维护一份数据集我更推荐照着“先收集类别-再统一映射-最后转换”的思路自己封装一个小工具脚本以后每次加数据都跑一遍省下的时间绝对比你当初写脚本用的时间多得多。