十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

labelme批量json转dataset:图像分割标注数据转换实战指南

labelme批量json转dataset:图像分割标注数据转换实战指南 标注了三百多张无人机航拍图晚上十一点守在电脑前对着四十多个json文件一个个执行labelme_json_to_dataset这种经历我猜不少做过图像分割的同学都有过。labelme 是当前最常用的开源图像标注工具它能让你快速画多边形把标注结果存成 json 文件里面记录的是每个对象的边界点坐标但模型训练真正需要的往往是一张和原图大小一致的像素级掩码图也就是我们常说的 label.png。从 json 到 dataset 的这一步转换官方给的是单文件命令行工具文件少时还好说一旦有几十上百个标注文件重复手动执行就是在浪费生命。这篇文章围绕“批量 json_to_dataset”这个主题展开我会先把官方脚本背后的原理讲清楚——它到底读了什么、写了什么、为什么要这么设计然后给出一套可以直接拿去用的 Python 批量转换方案最后把我在实际处理几百个标注文件时踩过的坑和排查思路整理成清单。适合人群很明确正在用 labelme 做语义分割、实例分割数据标注准备把标注结果转成训练数据集并且不想在重复劳动上耗时间的朋友。1. 为什么需要批量json_to_dataset标注完只是第一步1.1 labelme标注后模型训练还缺什么很多刚开始接触图像分割的同学会有个误区觉得用 labelme 画完框、存了 json数据准备工作就结束了。实际上 labelme 的 json 文件只是“标注中间产物”它保存的是每个对象的图形描述不是像素级标签。比如你画了一个多边形json 里记的是这个多边形的顶点坐标数组[[x1,y1],[x2,y2],...]以及对应的类别名称。神经网络训练时Block 要的是每个像素属于哪个类别这就需要把“点构成的矢量图形”转换成“像素构成的栅格掩码”。这个转换就是 json_to_dataset 的核心任务。官方脚本会把一个 json 文件扩展成一组文件img.png是原始图像label.png是每个像素带类别索引的掩码图label_viz.png是便于人眼检查的彩色可视化图label_names.txt是类别名称列表老版本还会输出一份info.yaml。这组文件才是真正能喂给 U-Net、DeepLab、Mask R-CNN 这类模型的数据形态。手动执行一次labelme_json_to_dataset 某个文件.json没什么问题但实际项目里标注数量很难停在个位数。以我自己做遥感分割的经验一百张图起步是常态每张图可能还有三四个类别。如果每个 json 都手动敲一次命令敲错路径、漏掉文件、重复覆盖都是大概率发生的事情。更麻烦的是标注过程中经常要返工第一天标完第二天发现第 17 张图某个物体的边界没画准重标之后要重新转换。这时候如果还靠手动效率和准确性都很难保证。1.2 官方脚本到底输出了什么先把官方转换结果理解透这样你才知道自己批处理脚本需要做什么、做完怎么验证。以老版本的json_to_dataset.py为例它读入一个 json 文件后输出目录是“json文件名去掉扩展名再加_json”的方式例如001.json会生成001_json文件夹。文件夹里有 5 类输出我逐个说img.png从 json 的imageData字段解码出来的原始图片。新版 labelme 如果保存时使用了--nodata参数json 里可能没有imageData需要从同行目录的原始图片读取。label.png像素级标签图每个像素的取值是整数0 表示_background_1、2、3 对应你在标注时定义的类别取哪个数字完全由 label 名称的排序决定不是标注顺序。label_viz.png在原始图片上叠加了类别颜色的可视化图我一般拿它做快速人工质检一眼就能看出某张图的标注有没有明显错位。label_names.txt一行一个类别名行号对应label.png里的像素值。比如第一行_background_第二行car那像素值 1 就代表 car。info.yaml同样保存了类别名称列表只是格式是 yaml。老训练代码经常读这个文件新脚本大多用label_names.txt就够了。这个输出结构本身比较合理label.pnglabel_names.txt已经构成了训练所需的全部标签信息img.png是源图label_viz.png纯粹是为了方便人看。批量脚本只要稳定地把这组文件生产出来并且保持目录结构一致下游训练代码基本不用改。1.3 批处理的三种路线对比把批量做起来大体上有三种路线我都试过分别说下感受。第一种是 shell 循环最简单粗暴。在终端里用for循环逐个调用命令行工具几行就能写完。优点是零改造缺点是错误处理几乎没有某个 json 转失败了你只能看到一堆堆的报错输出而且遇到中文路径或者一些奇怪字符时shell 变量处理很容易出问题。我早期偷懒用过一阵后来返工了几次就不想再碰了。第二种是在 Python 脚本里用subprocess调用官方命令行工具相当于给 shell 循环套了个 Python 壳。好处是可以批量执行并捕获返回码比纯 shell 灵活一点但本质上还是在进程中一次次启动独立命令效率不高而且调用的还是官方那个“单文件处理”逻辑遇到问题想改内部行为比如换输出目录名规则完全使不上劲。第三种是把官方脚本逻辑搬到自定义函数里在同一个 Python 进程内批量处理。这条路改造量稍大但可控性最强单文件转换被封装成函数主循环可以对每个文件做异常捕获、状态记录、断点续传甚至加多进程并行。下文的方案就走这条路。我实际用下来三百多个 json 在单进程下跑完也就是几分钟的事多进程并行后速度还能再翻几倍这已经足够覆盖绝大多数个人和中小团队的需求。2. 核心细节解析读懂json结构和官方脚本批量才能避开坑2.1 labelme的json文件里到底存了什么写批量脚本之前先把 json 的数据结构搞清楚不然你真不知道什么时候它就给你报个KeyError。一个标准 labelme json 文件最核心的字段有这几个versionlabelme 的版本号不同版本生成的文件结构基本一致但个别字段行为略有差异。flags分类任务标记通常为空对象比如做实例级标注时会存一些布尔属性。shapes标注对象数组每个对象包含label类别名、points顶点坐标数组、shape_type多边形、矩形等、group_id可选区分实例。imagePath原始图片的文件名注意这个字段通常只存文件名不存完整路径所以转换脚本要主动拼上 json 文件所在目录。imageData图片 base64 编码后的字符串。如果是用--nodata模式保存的这个字段会是null这时候必须走imagePath字段去读原图。imageHeight、imageWidth图片尺寸信息一般只是记录用转换过程中图像数组的形状会直接决定label.png的大小。points里坐标是浮点数不是整数。这在你手动缩放图片后重新标注时尤其重要比如编辑器里把图放大到 200% 去精标保存的坐标依然是相对原图的比例坐标shapes_to_label内部会自动把它们映射到像素网格上但如果你自己写转换逻辑就要先np.round再转int否则画掩码时很容易越界或出现缝隙。2.2 官方json_to_dataset的转换流程拆解官方脚本的逻辑其实非常清晰核心就五步。第一步读 json 文件拿到imageData如果为空就通过imagePath从磁盘读原图并做 base64 编码统一转成 numpy 数组。第二步扫描所有shapes给每个 label 分配一个从 1 开始的编号1 是第一个出现的类别2 是第二个依此类推同时把 0 预留给_background_。这里有个细节官方代码会对 shapes 按 label 名做排序这样即使同一个文件里对象出现的顺序不同生成的类别编号顺序也是稳定的。第三步是核心调用utils.shapes_to_label函数。这个函数的本质是先创建一个全 0 的uint8数组大小和原图一样然后遍历每一个 shape用多边形填充算法在对应像素位置上写入这个 shape 所属类别的编号。提示一下这个函数在历史版本中针对polygon、rectangle、circle等shape_type都有处理分支。新版 labelme 对circle的支持有过调整如果你手里的 json 里有这种类型且转换报错先检查一下 shape_type 是不是在支持列表里。第四步生成可视化图label_viz.png把原图和类别掩码叠加不同类别用不同颜色渲染这一步纯粹是给人看的。第五步写文件保存img.png、label.png、label_names.txt和info.yaml。label.png使用的是带调色板的单通道 PNG方便各类框架直接读取像素值做 loss 计算label_names.txt的行号就是像素类别编号。2.3 改造脚本时定义清楚边界把官方逻辑从“命令行工具”改造成“批量处理函数”不是简单把代码复制粘贴就完了先想清楚哪些逻辑要保持原样、哪些要改。我的原则是图片解码、shapes 遍历、类别编号分配、polygon 填充这些核心算法保持官方原样因为这些逻辑经过大量用户验证你乱改很容易引入隐蔽 bug。需要动的是外围封装输入从单个 json 路径变成“json 目录 输出根目录”输出目录规则继续沿用“原文件名去掉后缀 _json”保持下游训练代码不用改增加异常捕获和状态返回保证一个文件出错不影响整个批次增加跳过逻辑如果输出目录里已经有label.png说明这个文件之前已经成功转换过可以跳过。最后这条“断点续传”在真实项目里帮了我大忙。有一次批处理跑到一半机器自动更新重启了重新运行脚本后它自动跳过了已经转换完的 200 多个文件只处理剩下的几十个节省的时间不是一点半点。这个特性实现起来只要两行代码但体验提升非常明显。3. 实操过程手写一个能直接用的批量转换脚本3.1 环境准备与单文件验证为了让脚本稳定运行避免版本差异造成的折腾我建议单独建一个 conda 虚拟环境。Python 用 3.8 即可配合一个稳定版本的 labelme我实测下来labelme4.5.6比较省心它的utils模块还完整保留着shapes_to_label、draw_label、lblsave这些常用函数。conda create -n labelme python3.8 -y conda activate labelme pip install labelme4.5.6安装完成后先跑一个单文件确认环境没问题再上批量脚本。如果你用的是老版本 labelme可以直接执行labelme_json_to_dataset 某个文件.json验证如果用的新版就打开 Python 验证一下核心 API 是否可用。from labelme import utils print(utils.__file__)能正常打印路径说明可以继续。接下来先手动转换一个 json看看生成目录结构是不是符合预期。这是整个流程里最基础的“最小验证”别跳过这步否则后面批量脚本跑出来一堆错你很难分清是环境问题还是代码问题。3.2 把官方逻辑封装成单文件处理函数下面的脚本是我自己在项目中使用的版本做了精简但保留了全部核心功能。单文件处理函数接收两个参数json_file是待处理 json 的绝对路径out_root是输出根目录函数会在这个目录下生成“json文件名去掉后缀 _json”的子文件夹。import os import os.path as osp import json import base64 import glob import argparse import PIL.Image import numpy as np import yaml from labelme import utils def json_to_dataset(json_file, out_root): 把单个labelme json文件转换为训练用数据集 base_name osp.splitext(osp.basename(json_file))[0] out_dir osp.join(out_root, base_name _json) # 断点续传如果已经转换过直接跳过 if osp.exists(osp.join(out_dir, label.png)): return skipped, base_name with open(json_file, r, encodingutf-8) as f: data json.load(f) # 优先使用json内嵌图片缺失时从同行目录读取原图 imageData data.get(imageData) if not imageData: image_path osp.join(osp.dirname(json_file), data[imagePath]) with open(image_path, rb) as f: imageData base64.b64encode(f.read()).decode(utf-8) img utils.img_b64_to_arr(imageData) # 按label名排序保证类别编号稳定 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) lbl, _ utils.shapes_to_label(img.shape, data[shapes], label_name_to_value) label_names [None] * (max(label_name_to_value.values()) 1) for name, value in label_name_to_value.items(): label_names[value] name lbl_viz utils.draw_label(lbl, img, label_names) os.makedirs(out_dir, exist_okTrue) PIL.Image.fromarray(img).save(osp.join(out_dir, img.png)) utils.lblsave(osp.join(out_dir, label.png), lbl) PIL.Image.fromarray(lbl_viz).save(osp.join(out_dir, label_viz.png)) with open(osp.join(out_dir, label_names.txt), w, encodingutf-8) as f: for lbl_name in label_names: f.write(lbl_name \n) info dict(label_nameslabel_names) with open(osp.join(out_dir, info.yaml), w, encodingutf-8) as f: yaml.safe_dump(info, f, default_flow_styleFalse) return ok, base_name写这个函数时有几个细节值得注意。data[imagePath]存的通常只有文件名所以要手工拼上 json 所在目录否则离开原图目录运行脚本就会找不到图。给 json 读出文件加encodingutf-8是为了避免中文系统默认编码导致的中文标签读取异常。_background_始终固定在 0这是语义分割约定俗成的背景编号训练代码里一般也是这么处理的。3.3 批量遍历、断点续传与失败记录单文件函数有了剩下的就是主循环逻辑。这里我会做三件事遍历指定目录下所有 json 文件逐个调用上面的处理函数并用 try-except 捕获异常处理完打印汇总信息失败的写入failed_list.txt。def run_batch(json_dir, out_root): os.makedirs(out_root, exist_okTrue) json_files sorted(glob.glob(osp.join(json_dir, *.json))) ok_list, skip_list, fail_list [], [], [] for json_file in json_files: try: status, name json_to_dataset(json_file, out_root) if status ok: ok_list.append(name) else: skip_list.append(name) print(f[{status.upper()}] {name}) except Exception as e: fail_list.append((name, str(e))) print(f[FAIL] {name}: {e}) print(f\n完成成功 {len(ok_list)}跳过 {len(skip_list)}失败 {len(fail_list)}) if fail_list: with open(failed_list.txt, w, encodingutf-8) as f: for name, err in fail_list: f.write(f{name}\t{err}\n) if __name__ __main__: parser argparse.ArgumentParser(description批量转换labelme json为dataset) parser.add_argument(--json_dir, typestr, requiredTrue, help存放json文件的目录) parser.add_argument(--out_root, typestr, requiredTrue, help输出根目录) args parser.parse_args() run_batch(args.json_dir, args.out_root)把这段代码和上面的函数放在同一个文件里保存成batch_json_to_dataset.py运行方式就是python batch_json_to_dataset.py --json_dir labels --out_root dataset运行结束后labels目录下每个 json 都会对应生成一个dataset/xxx_json文件夹。failed_list.txt记录了所有失败文件及错误原因扫一眼这个文件比在终端里翻日志靠谱得多尤其当 json 数量上百时。3.4 转换结果验证脚本跑完不等于万事大吉我习惯做三层验证。第一层看目录数量用系统命令对比 json 数量和_json文件夹数量数量不一致就说明有漏转或失败的。ls labels/*.json | wc -l ls dataset/*_json -d | wc -l第二层检查 label 类别编号是否合理。随便打开一个结果目录里的label.png用一行 Python 就能统计像素值和类别数量。from PIL import Image import numpy as np lbl np.array(Image.open(dataset/0001_json/label.png)) values, counts np.unique(lbl, return_countsTrue) for v, c in zip(values, counts): print(f类别 {v}{c} 像素)正常情况第一行一定是类别 0 背景后面的类别编号从 1 开始连续递增。如果发现某个类别编号缺失比如 0、1、3 有值2 完全没有就要回头检查 label_names.txt八成是标注时类别名打错了字或者 json 里存在排序后又重复的 label。这个坑我踩过一次那个多出来的类别 2 其实是同一个类别的另一个拼写版本。第三层人眼抽查打开label_viz.png看可视化结果。这一步最好随机抽几张多类别的图重点看边缘对不对、有没有类别粘连。可视化文件存在的意义就在这里一个人工扫描的成本远低于模型训练到一半才发现标注错乱的返工成本。4. 常见问题与排查技巧实录4.1 高频报错与解决方案对照批量处理跑起来之后你大概率会遇到下面这些报错我按出现频率排序整理成表方便直接对照。现象常见原因解决办法KeyError: imageData或 imageData 为空保存 json 时用了--nodata图片没有内嵌让脚本从同行目录读取 imagePath 指向的原图配合第 3 节脚本的兜底逻辑AttributeError: module labelme.utils has no attribute draw_labellabelme 版本过新API 有变动安装稳定版本pip install labelme4.5.6或调整函数名ImportError: No module named yaml环境缺少 yaml 依赖pip install pyyamlUnicodeDecodeError或中文路径乱码Windows 下编码问题或 json 里 label 是中文读文件时显式指定encodingutf-8路径尽量改成纯英文label.png 全黑shapes_to_label 输入的类别编号映射错误检查 label_name_to_value 构建逻辑确认_background_占用了 0转换报错shape_type不支持json 里存在circle、line等类型确认 labelme 版本对类型的支持情况必要时把 circle 转成 polygon画掩码时坐标越界原图尺寸和 points 坐标不匹配检查 img 数组 shape 与 imageHeight/imageWidth 是否一致确认有没有中途缩放过图片第一个问题出现的频率最高。很多教程在保存时都推荐用--nodata来减小 json 体积但这样 json 里就没有imageData字段了。如果原图还放在 json 同级目录下上面的脚本会自动去读原图不会出错但如果你把 json 单独拷走了、原图没跟过去那就是神仙也救不了。所以我在项目里有个铁规原图和 json 永远放同一个目录目录可以整体拷贝但不要单独移动其中一类文件。中文标签的问题是国内开发者绕不开的痛点。labelme 本身支持在 label 里写入中文label_names.txt也保存得住但某些老版本脚本在写 yaml 文件时会对非 ASCII 字符报错。我的建议是label 名称尽量用英文或拼音不仅是技术兼容问题也是因为很多预训练分类器、数据增强库对中文标签名处理得并不好。如果项目里已经有中文标签了转换代码务必显式指定 utf-8 编码同时把失败文件及时导出来检查。4.2 效率优化与工程化扩展脚本能跑通以后如果你手上的 json 数量特别大比如上千个单进程的遍历速度就成了瓶颈。这时候可以用 Python 的multiprocessing做并行处理。需要注意的是多进程在 Windows 下必须把主循环放到if __name__ __main__:里否则会无限递归启动子进程。from multiprocessing import Pool def worker(args): json_file, out_root args return json_to_dataset(json_file, out_root) if __name__ __main__: json_files sorted(glob.glob(osp.join(args.json_dir, *.json))) with Pool(processes8) as pool: results pool.map(worker, [(jf, args.out_root) for jf in json_files]) for status, name in results: print(f[{status.upper()}] {name})并行与否取决于你的机器。实测处理三百个文件单进程大概五分钟8 进程能压到一分钟左右速度提升明显。不过并行模式下的失败记录会打散在终端里所以我一般先用单进程跑一遍全量确认没有批量性错误后再切并行重跑剩余文件反正有断点续传兜底重复执行很安全。再说两个工程化扩展方向。如果你下游要接的还是式数据集比如 COCO 或 VOC批量 json_to_dataset 已经帮你把像素掩码生成好了接下来要做的只是把label_names.txt转成 COCO 的类别字典把多边形坐标转成 COCO 的segmentation格式工作量会小很多。另一个方向是半自动标注原始 json 里其实保留了每个对象的group_id有了像素掩码后你可以很方便地写代码把同一类别的独立连通域提取出来做实例分割数据这一步在很多项目中都是刚需。最后再分享一个我自己的使用习惯。脚本会把失败记录写进failed_list.txt但这玩意儿处理完一批就该改名存放别让它一直留在项目根目录里。我有一次重跑任务时忘了它盯着终端看了半天以为这一批全失败了实际是上批的旧文件自己吓自己。批处理发展到后面比代码更重要的是你对过程状态的掌控感快捷、可控、可批量复现这才是脚本存在的意义。
返回列表