
上次做一个分割项目标注同学一次性丢给我512个labelme标注好的json文件我第一反应是用labelme自带的json_to_dataset一个个转。转了不到五十个就受不了每个文件要敲一次命令输出目录还经常因为上一次转换残留而报错偶尔一个文件标注不规范整个流程直接断掉还得回头找是哪个文件出了事。当时我就决定必须把“批量json_to_dataset”这件事彻底解决掉。这篇就把我从源码层面拆解、到写批处理脚本、再到踩坑验证的完整过程写出来给同样被这个问题卡住的读者一份可以抄作业的方案。1. labelme官方json_to_dataset的原始痛点1.1 先搞清楚这个命令原本怎么用labelme是图像标注工具里的老面孔了安装也很简单pip install labelme之后会带出两个命令行入口labelme用来打开标注界面labelme_json_to_dataset用来把标注好的json文件转换成训练用的数据集格式。这个转换命令的基本用法是labelme_json_to_dataset 某个json文件路径转换完成后会在原json同级目录下生成一个同名文件夹里面包含img.png原图、label.png像素级标注图、label_viz.png标注可视化叠加图、label_names.txt类别名单。不同版本可能还会多出一个info.yaml记录一些版本和类别信息。这个输出结构就是后续做语义分割、实例分割训练时最常用到的数据形态。问题在于这个命令一次只接受一个json文件。你有几个json就得敲几次命令。手动敲几次还好一旦上了量级尤其是从标注同学手里接回来的完整标注包少则几十、多则几百上千个json文件这种单文件处理模式就完全失控了。很多教程只讲了“怎么转单个文件”真正到了项目交付阶段批量转换才是绕不开的坎。1.2 单文件模式在真实项目中为什么不够用我总结了三个让单文件模式在真实项目里非常难受的场景。第一个是数量大带来的重复劳动。500个json就要敲500次命令哪怕你把命令复制到循环里执行一旦中间某个文件转换失败整个批量流程就会停下来。你得手动去排查是哪个文件坏了跳过它再从断点继续。这种“盯梢式”操作浪费的时间比转换本身还多。第二个是输出目录管理混乱。默认情况下每个json转换出来的文件夹都散落在json同级的目录里。后续做数据集划分、类别统计、增广预处理都得先去这些杂乱的目录里捞文件。如果你的原始json目录和图片目录混在一起情况会更糟。第三个是版本差异导致的目录冲突。旧版本的labelme_json_to_dataset在输出目录已存在时会自动把旧目录删掉再重建但新版本环境里遇到同名目录有时会直接报FileExistsError。也就是说同一个批量脚本在不同版本下行为可能完全不同。我在某个环境里能跑通的批量循环换到另一个环境就卡死了。这些痛点本质上不是“转换”本身的问题而是labelme官方脚本只承担了“演示最小用例”的职责。官方cli里没有批处理入口也没有做异常隔离和目录规划这些工程化的事情需要我们自己补上。2. 从源码看懂json_to_dataset的处理链路2.1 json文件本身是完整的标注协议要写批量转换脚本先得搞清楚labelme的json文件里到底存了什么。labelme生成的json格式本质上是一份完整标注协议核心字段包括version标注器版本号。flags一些全局标志位一般不用。shapes标注对象数组每个对象包含label类别名、points多边形顶点坐标、shape_type多边形/矩形/圆/线/点、group_id分组ID。imagePath原始图片的相对路径。imageData原图的base64编码字符串。imageHeight、imageWidth图片尺寸。这里最关键的一个点是json里通常已经包含了完整原图数据也就是imageData字段。所以json_to_dataset在做的事情本质上不是“从外部找原图来对齐”而是“把json里的坐标信息画到json里已经携带的原图上”。这也是为什么官方脚本可以直接在没有任何额外输入的情况下生成img.png和label.png。如果标注时配置了不把图片数据写进json有些团队为了减小json体积会这么干json里的imageData字段就会是空的。这种情况下转换脚本才需要根据imagePath字段去读取外部原图。这个细节看起来很基础但在写批处理脚本时是一个必须处理的分支官方脚本在新版里也做了类似回退但不同版本写法不一样。2.2 官方脚本从json到dataset的完整数据流以常见5.x版本的labelme为例官方json_to_dataset脚本位于labelme/cli/json_to_dataset.py整个处理链路可以拆成五步第一步读取json文件把它解析成Python字典或JsonAnnotation对象。这里会取出imageData、shapes、imagePath等字段。第二步从imageData中通过base64解码得到原始图像数组也就是img。第三步构建label_name_to_value映射。官方默认把background固定映射为0然后遍历shapes里的所有标注目标把出现过的类别名依次映射到1、2、3……这个映射关系非常重要因为它直接决定了后续label.png里每个像素的数值含义。不同json文件里即使包含相同类别只要标注顺序不同就会有重新编号的风险这是我后面踩过的一个大坑。第四步遍历shapes里的每个标注对象根据points和shape_type生成对应的二值mask然后把这个mask所在的像素区域在lbl数组里赋值为当前类别对应的索引值。多边形、矩形、圆、线、点分别走不同的绘制逻辑官方通过shape_to_mask这个工具函数统一处理。第五步把img保存为img.png把lbl保存为label.png用draw_label生成一张可视化预览图保存为label_viz.png再把类别名单写成label_names.txt。整个流程本身不复杂但官方脚本的设计目标是“给你看一个最小实现”所以它没有循环、没有断点续转、没有错误隔离、没有目录规划。这些工程化的东西官方选择留给使用者自己处理。2.3 可以复用的三个底层函数读源码最大的收获是发现真正值得复用的不是json_to_dataset这个命令行入口而是labelme.utils模块里的底层函数。这三个函数跨版本都比较稳定建议直接围绕它们来写自己的批量逻辑。第一个是img_b64_to_arr负责把json里的base64图片字符串解码成numpy数组。只要imageData字段存在它就是最可靠的取图方式。第二个是shape_to_mask它接收图片尺寸、多边形顶点、shape_type三个参数返回一个布尔mask数组。官方脚本里所有多边形、矩形、圆形、线、点的转换最终都走这个函数。它处理了不同shape_type的边界绘制逻辑比我自己用cv2.fillPoly再补各种形状分支要省事得多。第三个是draw_label它把整数索引的lbl数组和类别名单组合起来生成一张带颜色的可视化图。label_viz.png就是靠它生成的。批量转换之后用这张图做抽检能很快看出标注有没有错位、漏标、异常区域。另外label_name_to_value这个映射的构建逻辑也是可以复用的核心。我建议固定从0开始0保留给背景后续类别按一定顺序依次递增。官方脚本里有一个sorted操作目的是让同一个类别集合在不同json里生成相对稳定的编号顺序这个细节在自己写批量脚本时也要保留。3. 批量转换的三条技术路线怎么选3.1 shell for循环快速但脆弱最简单粗暴的批量方案是直接写一个shell循环来调用官方命令行for f in /path/to/jsons/*.json; do echo converting $f labelme_json_to_dataset $f || echo failed: $f done如果你只是临时转十来个json这个方案五分钟就能搞定不需要写Python代码。但放到几百个文件的量级上它的缺点就很明显了每循环一次就要重新启动一次Python解释器、加载一次labelme相关模块性能开销大出现目录已存在的报错时行为不稳定某个文件转换失败循环虽然通过||继续跑但错误信息非常简陋事后排查困难。所以我的判断是shell循环只适合“临时救急”和“验证想法”不适合作为数据管道的正式一环。3.2 Python调用底层函数推荐的中间路线更推荐的方案是写一个独立Python脚本在脚本里循环遍历json文件每个文件调用上一节说的底层函数完成转换。这种方案的好处有几个只启动一次Python解释器所有转换都在一个进程内完成速度快很多。可以精确控制每个文件转换过程中的异常单个坏json不会让整批任务中断。可以自由定制输出目录结构比如按json文件名分目录或者输出到指定根目录。转换完成后还能统一打印成功、失败、跳过的统计信息。这条路线兼顾了开发效率和工程可控性是我最终落地的方案。它比纯shell循环多一些代码量但换来的是稳定性和可维护性尤其是当你的json数量从几十涨到几百上千时差距会非常明显。3.3 完全自定义解析按需改造的最高自由度第三种路线是完全自己解析json文件不依赖labelme.utils。比如直接用cv2.fillPoly画多边形或者用PIL.ImageDraw画线画点。这种方案适合几种特殊场景不想为几个文件安装整个labelme包需要把labelme的json转换成COCO、YOLO等其他格式需要对mask做特殊的后处理比如闭运算、连通域分析、实例级分割。缺点也很直接你需要自己处理矩形、圆、线、点等不同shape_type的绘制逻辑还要自己维护类别映射、编码、图像格式等细节代码量和出错概率都会显著上升。除非有明确需求否则我不建议一上来就走这条路线。三条路线的取舍我在实际项目中判断标准很简单能用底层函数解决的事情不重复造轮子需要跨格式或定制化处理的部分再单独写扩展代码。下表是我自己常用的选型参考方案依赖代码量可控性适用场景shell for循环labelme命令行极低低临时少量转换Python调用底层函数labelme库中高批量转换、数据集整理完全自定义解析无高极高格式转换、特殊后处理4. 一个能直接跑的批量转换脚本4.1 完整参考实现把前面的思路整理成代码下面这个脚本是我在实际项目里用过的版本简化了一些项目特定逻辑保留了核心框架。只要安装了labelme和Pillow就能直接运行。import argparse import json import os import shutil import numpy as np import PIL.Image import labelme.utils as lutils def convert_one(json_file, out_root, overwriteTrue): 将单个labelme json转换为 img.png / label.png / label_viz.png / label_names.txt json_name os.path.splitext(os.path.basename(json_file))[0] out_dir os.path.join(out_root, json_name) if os.path.exists(out_dir): if overwrite: shutil.rmtree(out_dir) else: return False, 输出目录已存在 os.makedirs(out_dir) with open(json_file, encodingutf-8) as f: data json.load(f) # 优先使用json内嵌的imageData为空时回退到imagePath if data.get(imageData): img lutils.img_b64_to_arr(data[imageData]) else: image_path os.path.join(os.path.dirname(json_file), data[imagePath]) img np.asarray(PIL.Image.open(image_path)) # 构建类别映射_background_固定为0其他类别按排序依次递增 label_name_to_value {_background_: 0} for shape in sorted(data[shapes], keylambda x: x[label]): label_name shape[label] if label_name not in label_name_to_value: label_name_to_value[label_name] len(label_name_to_value) # 逐形状绘制mask并写入lbl lbl np.zeros(img.shape[:2], dtypenp.int32) for shape in data[shapes]: label_name shape[label] points shape[points] shape_type shape.get(shape_type, polygon) mask lutils.shape_to_mask(img.shape[:2], points, shape_type) lbl[mask] label_name_to_value[label_name] # 生成可视化预览图 label_names [None] list(label_name_to_value.keys()) lbl_viz lutils.draw_label(lbl, img, label_names) # 保存结果 PIL.Image.fromarray(img).save(os.path.join(out_dir, img.png)) PIL.Image.fromarray(lbl.astype(np.uint8)).save(os.path.join(out_dir, label.png)) PIL.Image.fromarray(lbl_viz).save(os.path.join(out_dir, label_viz.png)) with open(os.path.join(out_dir, label_names.txt), w, encodingutf-8) as f: for name in label_name_to_value: f.write(name \n) return True, json_name def main(): parser argparse.ArgumentParser(description批量执行labelme json转dataset) parser.add_argument(--json-dir, requiredTrue, help存放json文件的目录) parser.add_argument(--out-dir, requiredTrue, help输出根目录) parser.add_argument(--no-overwrite, actionstore_true, help输出目录已存在时跳过) args parser.parse_args() os.makedirs(args.out_dir, exist_okTrue) json_files [f for f in os.listdir(args.json_dir) if f.endswith(.json)] json_files.sort() ok_count 0 failed_list [] for name in json_files: json_file os.path.join(args.json_dir, name) try: success, msg convert_one(json_file, args.out_dir, overwritenot args.no_overwrite) if success: ok_count 1 print(f[OK] {name}) else: failed_list.append((name, msg)) print(f[SKIP] {name}: {msg}) except Exception as exc: failed_list.append((name, str(exc))) print(f[FAIL] {name}: {exc}) print(f\n完成成功 {ok_count} 个失败/跳过 {len(failed_list)} 个) for name, msg in failed_list: print(f - {name}: {msg}) if __name__ __main__: main()这个脚本之所以放一个overwrite开关是因为平时批量转换时同一个目录如果重新执行默认应该清理旧结果重来否则容易混入过期文件。但某些场景下你可能只想转新增的json改成--no-overwrite就能跳过已有结果。4.2 关键设计细节为什么这样写有几个细节是反复调整后留下的值得单独说明。imageData为空时的回退逻辑是必须有的。很多标注团队为了减小json体积会在保存时关闭图片内嵌导致json里的imageData为空。如果不处理这个分支批量转换会在大约一半文件上失败。回退到imagePath读取外部图片时注意路径是相对于json文件所在目录的直接用os.path.join拼接即可。label_name_to_value的构建用了sort排序这是为了尽量保持同类别的索引在不同json之间一致。比如多个json里都有“car”这个类别排序后它在映射中的位置大概率是稳定的这样后期把多张label图拼接到一起做分析时不会因为编号错位导致类别混乱。当然最严格的方案是在整个数据集范围内统一构建类别映射表但那就超出单文件转换的范畴了需要在数据整理阶段单独处理。mask赋值这步直接用lbl[mask] 索引值。这样一张图里先画的区域可能被后画的区域覆盖符合标注工具的交互逻辑。如果你在处理重叠标注时希望保留全部实例就需要另外做实例级标记不考虑在普通语义分割流程内。4.3 运行效果与输出目录运行方式很简单python batch_json_to_dataset.py --json-dir ./annotations --out-dir ./datasetannotations目录下放所有jsondataset目录会自动创建。转换完成后目录结构长这样dataset/ 001_car_01/ img.png label.png label_viz.png label_names.txt 001_car_02/ img.png label.png label_viz.png label_names.txt ...每个json文件单独一个子目录命名和json文件名保持一致后续做数据集划分、类别统计都很好定位。我在实测中转512个json大部分是单张1080p图片加多边形标注整个流程大概两分多钟中间有三个文件因为标注不规范被单独记录下来我把它们挑出来重新标注后就补齐了。5. 实战中我踩过的坑和快速自检方法5.1 环境与安装层面的坑labelme的安装本身不复杂pip install labelme就行但真上手时还是有几个高频问题。命令找不到是最常见的尤其是通过conda或虚拟环境安装时。pip install之后labelme_json_to_dataset命令所在目录可能没有加入PATH在Windows上通常是Python安装目录下的Scripts文件夹。遇到这种情况先用pip show labelme确认安装路径再用完整路径调用命令或者直接激活正确环境。环境版本混淆也容易出事。有些人base环境是Python 3.6为了跑其他项目装了老版本labelme代码里调用的API和新版本不一样。比如旧版shape_to_mask的传参方式和新版就有区别。我建议在转换脚本里统一以当前环境的labelme版本为准必要时用dir(lutils)快速确认函数签名不要盲抄网上老教程的代码。还有一个和GUI相关的坑labelme导入时会尝试加载PyQt5相关模块如果服务器环境没有显示器import labelme可能报错。但只做json转换其实用不到GUI这种情况下可以设置环境变量或选择轻量方式至少要意识到这个现象和GUI无关不要误判成标注数据损坏。5.2 标注数据层面的坑标注数据本身的问题比环境问题更隐蔽也更值得花时间排查。第一个是类别命名不一致。同一个“轿车”类别两个标注员一个写“car”一个写“Car”批量转换脚本会把它们当成两个独立类别处理。表面上看转换流程没有问题但训练时模型会多学一个无效类别mAP和mIoU都会受影响。批量转换后一定先查看所有label_names.txt确认类别集合是否符合预期。第二个是shape_type的多样性。大多数标注用的都是polygon但矩形、圆、线、点也可能会混在shapes里。如果你自己写解析逻辑一定要处理好这些分支。用labelme.utils的shape_to_mask可以省掉这部分工作量但要注意不同shape_type对points数量的要求不一样点标注的points只有一个坐标转换时如果不做判断很容易数组越界。第三个是json文件里的img.png和原图不一致。常见情况是标注时图片被缩放或旋转过但json里的imageData还是原始图像。转换后你拿到的img.png可能和标注界面里看到的不完全一样。遇到这种情况最可靠的办法是看json里的imageWidth和imageHeight字段与转换后img.png的尺寸对比不一致就说明中间有预处理环节需要找标注同学确认。5.3 转换结果的快速验证批量转换之后别急着把数据送去训练先花几分钟做一次快速自检。我的习惯是三步检查。第一步抽看label_viz.png。选几张背景复杂、目标密集的图肉眼确认标注轮廓和物体边缘是否贴合。这一步能发现坐标偏移、漏标、类别错乱等显而易见的问题。第二步检查label.png的灰度值分布。label.png是单通道索引图每个像素值对应一个类别索引。用下面这个命令可以快速查看灰度值集合python -c import numpy as np; from PIL import Image; imnp.array(Image.open(dataset/001_car_01/label.png)); print(im.min(), im.max(), np.unique(im))正常情况下灰度值集合应该是0到类别数减1的连续整数比如0、1、2、3。如果中间缺了几档或者出现了超出预期范围的数值说明类别映射或mask绘制有问题。第三步多个json之间做交叉验证。把所有label_names.txt汇总看类别集合是否一致、每个类别是否在所有相关json中都存在。这一步可以快速定位某些文件漏标或类别名写错的情况。我把这个检查写成了一个独立脚本在批量转换后自动执行几秒种就能出报告。6. 批量转换之外的数据集管理小改造6.1 顺手统计类别分布批量转换做完之后下一个常见需求是统计整个数据集的类别分布。这个时候所有json已经转成了label.png统计就变得非常简单了。一行代码就能算每个类别的像素占比再结合实例数做一些可视化。我通常会在批量转换脚本后面串一个统计步骤遍历所有label.png用np.bincount统计每个像素值出现的次数再除以总像素数得到每个类别的面积占比。面积占比异常的数据往往意味着标注质量问题。比如某个类别在全部图中面积占比极低可能是漏标或者标注不完整。6.2 并行提速的小实践如果json数量特别多比如几千上万张单进程转换虽然可用但还能再提速。这类转换的瓶颈主要在原图解码和mask绘制上不同json文件之间没有依赖关系天然适合并行处理。用concurrent.futures的ProcessPoolExecutor就能做简单的多进程改造核心思路是把原来的for循环拆成一个可提交的独立函数然后让进程池自动分配任务。有一点要特别注意Windows环境下多进程必须写在ifname main保护区域内否则子进程重新导入脚本时会递归执行main后果很惨。实际测试中我这边用4个worker处理512个json时间大约从两分半缩短到一分钟左右。如果单张图比较大这个提速效果会更明显。但并行后每个json独立写自己的输出目录所以不会出现写冲突的问题错误收集逻辑也和单进程保持一致。6.3 把脚本沉淀成团队工具时的习惯批量转换脚本一旦从“自己用”变成“团队用”就要额外注意几个习惯。参数化是第一位。json目录、输出目录、是否覆盖、是否并行、是否输出统计报告都应该做成命令行参数而不是直接写在代码里。这样不同项目可以复用同一套脚本不用每次改代码。日志要完整。批量转换过程中除了打印进度最好把每个文件的执行结果落盘到一个日志文件里。这样如果后续发现某些数据有问题可以回查是转换时出的错还是原始标注就坏了。我在实际项目里就遇到过转完一周后被人问某个文件为什么缺失的情况最后靠日志才定位到是原始json格式不完整。错误不能吞。脚本里catch了每个文件的异常但这不意味着可以忽略它们。failed_list里的错误信息一定要保留原始堆栈的最小上下文方便排查。我习惯把异常信息统一写入一个failed.txt这样无论是自己还是同事接手都能很快知道哪些文件需要返工。我在实际项目里最常用到的其实是把这个批量转换脚本跟数据划分脚本串起来先做一次类别统计确认没有漏标和异常分类再按比例划分train/val最后才开始训练。批量json_to_dataset这件事本身不大但改完之后整个数据管道都顺了。如果你也有类似的批处理需求建议先花十分钟把官方脚本里能复用的函数挑出来再围绕它们做自己的循环和异常处理往往比硬啃命令行省事得多。