ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

labelme2yolo关键点格式转换:从JSON到txt的完整流程与避坑指南

labelme2yolo关键点格式转换:从JSON到txt的完整流程与避坑指南

做关键点检测项目的人大多绕不开一个尴尬的环节:标注工具和训练框架各说各话。我最近在整理一套自用的姿态关键点数据流程,标注端一直用 labelme,因为它画点、拖框、改标签都顺手,团队里非技术背景的同事也能快速上手;但训练端用的是 YOLO 系列的关键点检测模型,它只认自己那套 txt 标注格式。于是每次数据更新,都要在 labelme 的 JSON 和 YOLO 的 txt 之间来回倒腾一次。这个“labelme2yolo 格式改变”的活儿看着不起眼,真做起来坑不少:坐标归一化算错、关键点顺序对不上、可见性标志丢失、图像尺寸读错,任何一个环节出问题,训练时 loss 就是降不下去,你还以为是模型结构的问题,排查半天才发现是标注转换错了。

这篇内容就是把我自己这套转换流程完整拆开讲清楚。它解决的核心问题很具体:把 labelme 标注的关键点 JSON 文件,稳定、可复现地转换成 YOLO 关键点检测所需的 txt 格式,同时保留关键点的顺序语义和可见性信息。适合正在做单人姿态、手部关键点、工业部件定位点这类项目的同学参考,无论你是刚接触关键点检测的新手,还是已经跑通过一遍但想搞清楚细节的老手,都能从里面找到能直接抄作业的步骤和踩坑经验。下面我按设计思路、核心细节、实操实现、问题排查四个部分展开,中间会穿插我自己实测下来的参数选择和避坑技巧。

1. 整体设计思路与格式差异拆解

动手写转换脚本之前,得先把两边的数据格式彻底搞明白。很多人一上来就搜现成脚本,复制过来跑不通,然后开始瞎改,根本原因是对两种格式的字段含义理解不到位。我习惯先把格式差异列成一张表,对着表写代码,逻辑会清晰很多。

1.1 labelme 关键点标注的 JSON 结构

labelme 保存的每个 JSON 文件对应一张图,里面最关键的几个字段是imagePath、imageWidth、imageHeight、imageData(可选)和shapes。关键点标注在shapes里,每个 shape 有一个label、一个shape_type(关键点通常是point)、以及points数组。对于点来说,points里就一个坐标[x, y],单位是像素。

这里有个容易被忽略的点:labelme 的关键点本身不带“可见性”字段。它只有坐标,没有“这个点被遮挡了”或者“这个点在图外”的标记。而 YOLO 关键点格式是要求可见性标志的(0 表示未标注、1 表示标注但不可见、2 表示可见)。所以转换时我们必须自己定一套规则来生成可见性,常见做法是:只要 labelme 里画了这个点,就认为可见性为 2;如果某个关键点缺失,就填 0 并把坐标补 0。这套规则必须在整个数据集上统一,否则模型学到的可见性语义会乱。

另外,labelme 的shapes里可能同时存在检测框(rectangle)和关键点(point)。YOLO 关键点格式要求每行先有一个目标框,再跟若干关键点。所以转换时要先把框和点配对起来。我的做法是:约定每个目标一个框,框的 label 和点的 label 用命名前缀关联,比如框叫person,点叫person_nose、person_left_eye这样,靠前缀把点归到对应的框下面。这个约定在标注阶段就要跟标注人员讲清楚,不然后期配对逻辑会写得很痛苦。

1.2 YOLO 关键点 txt 格式的字段含义

YOLO 关键点检测的标注是一张图一个 txt,每行描述一个目标。一行的结构是:类别索引 cx cy w h px1 py1 v1 px2 py2 v2 ...。其中cx cy w h是目标框的中心点和宽高,全部按图像宽高归一化到 0 到 1;后面每个关键点是三个值:归一化后的 x、归一化后的 y、可见性 v。

归一化这一步是错误高发区。归一化的分母是图像宽度和高度,不是框的宽高。我见过有人把关键点坐标减去框左上角再除以框宽高,那是另一种相对框的表示法,YOLO 关键点格式不认这个。正确做法就是px = x / imageWidth、py = y / imageHeight,简单直接,但一定要确认imageWidth和imageHeight读的是 JSON 里的字段而不是 PIL 重新打开图片读的尺寸,两者理论上一致,但图片被替换过而 JSON 没更新时就会不一致,这种脏数据要提前过滤。

还有一个细节:关键点的顺序必须固定。YOLO 训练时是按索引位置理解每个点的语义的,第 0 个点永远代表鼻子,第 1 个点永远代表左眼,以此类推。所以转换脚本里要维护一个全局的关键点名称到索引的映射表,所有样本都按这张表输出,缺失的点补 0。这张表一旦定下来就不能随便改,改了就得重新训练,否则模型会把语义搞混。

1.3 为什么选择脚本转换而不是手工处理

有人会问,数据量不大的话能不能手工改?我的经验是绝对不要。关键点数据动辄几千个点,手工改不仅慢,而且归一化的小数位、可见性标志这些细节极容易出错,出错后还很难定位。写一个一次性投入的脚本,后面数据更新直接重跑,才是可持续的做法。

脚本转换的另一个好处是可审计。转换过程中可以加校验:检查每个框是否有点、检查坐标是否越界、检查关键点数量是否和映射表一致。这些校验在手工处理时几乎不可能系统化地做。我在脚本里加了一个统计输出,每次转换完打印总样本数、缺失关键点的样本数、坐标越界的样本数,一眼就能看出数据质量。这个习惯帮我提前发现过好几次标注错误,比如标注人员把点画到了图像外面。

2. 核心细节解析与实操要点

格式搞清楚了,接下来是转换过程中真正决定成败的细节。这一部分我按“读什么、配什么、算什么、写什么”的顺序讲,每个环节都附上我实际踩过的坑。

2.1 图像尺寸的读取与校验

归一化依赖图像宽高,所以第一步是把宽高拿准。labelme 的 JSON 里有imageWidth和imageHeight字段,直接读这两个值是最稳的,因为它就是标注时用的尺寸。但有个前提:JSON 和图片必须是一一对应的,且图片没有被替换过。我遇到过一种情况,同事重新导出了一批图,尺寸变了,但 JSON 还是旧的,结果归一化全错。所以脚本里我加了一步校验:用 PIL 打开imagePath指向的图片,读实际尺寸,和 JSON 里的字段比对,不一致就跳过并记录到日志。这一步多花几秒钟,能省掉后面几小时的排查。

如果图片路径在 JSON 里是相对路径,要注意脚本运行时的当前工作目录。我的做法是统一以 JSON 文件所在目录为基准拼接图片路径,这样不管从哪个目录运行脚本都不会找不到图。拼接后如果文件不存在,也记录到日志,不中断整个转换流程,因为批量数据里偶尔有一两张图丢失是常事,不能因为一张图让整个任务失败。

2.2 框与关键点的配对逻辑

配对是转换里最需要设计的一环。前面说了用命名前缀关联,具体实现是:先遍历shapes,把shape_type为rectangle的收集成框列表,把shape_type为point的收集成点列表。然后对每个框,按它的label作为前缀,去点列表里找 label 以“前缀 + 分隔符”开头的点。分隔符我习惯用下划线,比如框person对应点person_nose。

这里有个边界情况要处理:一张图里可能有多个同类目标,比如两个人。这时候两个框的 label 都是person,靠前缀就分不清哪个点属于哪个人了。解决办法是在标注阶段就给每个目标加编号,比如person_1、person_2,点叫person_1_nose、person_2_nose。这个约定必须在标注规范里写死,转换脚本按编号分组即可。如果标注阶段没做这个约定,后期补救很麻烦,只能靠点的空间位置去猜它属于哪个框,用点在框内的规则做归属,但点落在框外或者两框重叠时就不可靠了。所以我的建议是:多目标场景,标注时就把编号加上,别偷懒。

配对完成后,还要检查每个框是否配到了预期数量的关键点。如果某个框只配到一半的点,说明标注漏了,这时候可以选择跳过该样本或者补 0。我倾向于跳过并记录,因为关键点缺失太多的样本对训练是负贡献。跳过阈值可以设成“缺失超过 30% 就跳过”,这个比例可以根据项目容忍度调整。

2.3 归一化计算与数值精度

归一化的公式前面说了,x / width、y / height,但数值精度有讲究。YOLO 的 txt 一般保留 6 位小数就够了,保留太多位没必要还增大文件体积。我用的是格式化字符串%.6f,实测下来训练完全没问题。要注意的是坐标可能因为标注误差略微超出图像边界,比如 x 等于 width 加 1,归一化后大于 1。这种值要不要截断到 0 到 1 之间?我的做法是截断,因为超出边界的点对模型没有意义,截断后至少不会引入异常梯度。截断的同时记录一条日志,方便回头检查标注质量。

框的归一化同理,cx = (x1 + x2) / 2 / width、w = (x2 - x1) / width。这里要确保 x1 小于 x2,labelme 画框时如果从右下往左上拖,存下来的点顺序可能是反的,所以计算前先对两个角点做 min/max 排序。这个小细节不处理,算出来的宽高会是负数,训练直接报错。

2.4 关键点顺序映射表的维护

映射表是整个转换的“宪法”。我把它写成一个 Python 字典,键是关键点名称后缀(比如nose、left_eye),值是对应的索引。所有样本输出时,按索引 0 到 N-1 的顺序依次取点,取不到就填0 0 0。这张表要和训练时的配置保持一致,YOLO 的模型配置里有个kpt_shape参数,比如[17, 3]表示 17 个关键点、每个点 3 个值,这个 17 必须和映射表的长度一致。

映射表最好单独存成一个配置文件或者常量模块,转换脚本和训练配置都引用它,避免两处各写一份导致不一致。我吃过这个亏:转换脚本里加了第 18 个点,训练配置忘了改,结果训练时维度对不上,报错信息还不直观,查了半天。现在我的做法是映射表只在一个地方定义,其他地方 import 引用,改一处全生效。

3. 实操过程与核心环节实现

这一部分给出可以直接跑的完整流程。我用 Python 实现,依赖只有标准库加 Pillow,不需要装额外的深度学习框架,轻量好部署。下面按步骤走,关键代码贴出来并解释。

3.1 环境准备与目录结构约定

先约定目录结构,这能让脚本逻辑简单很多。我习惯这样组织:

dataset/ images/ train/ val/ labels_json/ train/ val/ labels_yolo/ train/ val/

labels_json放 labelme 导出的 JSON,labels_yolo是脚本输出的 txt,目录结构镜像对应。这样训练时直接指向labels_yolo即可。环境上只需要 Python 3.8 以上和 Pillow,安装命令就一行:

pip install pillow

不需要装 labelme 本身,因为脚本只读 JSON,不依赖 labelme 的运行时。这一点对部署到没有图形界面的服务器上很有用。

3.2 关键点映射表与配置定义

先定义映射表和类别表。假设做的是单人 5 关键点(头、左手、右手、左脚、右脚)的简化姿态任务,映射表这样写:

# config.py KEYPOINT_NAMES = ["head", "left_hand", "right_hand", "left_foot", "right_foot"] KEYPOINT_INDEX = {name: i for i, name in enumerate(KEYPOINT_NAMES)} CLASS_NAMES = ["person"] CLASS_INDEX = {name: i for i, name in enumerate(CLASS_NAMES)} SEPARATOR = "_"

KEYPOINT_INDEX用推导式生成,保证索引和名称顺序严格对应。类别同理。分隔符单独定义,方便以后改。这个文件被转换脚本 import,训练配置里也引用len(KEYPOINT_NAMES)作为kpt_shape的第一个值,做到单一数据源。

3.3 单个 JSON 到 txt 的转换函数

核心转换函数按前面讲的逻辑实现,我把它拆成几个小函数,便于测试。先写读取和校验:

import json import os from PIL import Image def load_and_validate(json_path): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) img_path = os.path.join(os.path.dirname(json_path), data["imagePath"]) if not os.path.exists(img_path): return None, "image_missing" with Image.open(img_path) as im: real_w, real_h = im.size if real_w != data["imageWidth"] or real_h != data["imageHeight"]: return None, "size_mismatch" return data, None

这个函数返回数据和错误码,调用方根据错误码决定跳过还是继续。size_mismatch和image_missing都记录到日志,不抛异常,保证批量处理不中断。

接着写配对和转换:

def convert_one(data): w, h = data["imageWidth"], data["imageHeight"] rects, points = [], [] for shape in data["shapes"]: if shape["shape_type"] == "rectangle": rects.append(shape) elif shape["shape_type"] == "point": points.append(shape) lines = [] for rect in rects: prefix = rect["label"] (x1, y1), (x2, y2) = rect["points"] x1, x2 = min(x1, x2), max(x1, x2) y1, y2 = min(y1, y2), max(y1, y2) cx = (x1 + x2) / 2 / w cy = (y1 + y2) / 2 / h bw = (x2 - x1) / w bh = (y2 - y1) / h kpt_map = {} for p in points: label = p["label"] if label.startswith(prefix + SEPARATOR): suffix = label[len(prefix) + len(SEPARATOR):] if suffix in KEYPOINT_INDEX: kpt_map[KEYPOINT_INDEX[suffix]] = p["points"][0] cls_id = CLASS_INDEX.get(prefix.split(SEPARATOR)[0], 0) parts = [str(cls_id), f"{cx:.6f}", f"{cy:.6f}", f"{bw:.6f}", f"{bh:.6f}"] for i in range(len(KEYPOINT_NAMES)): if i in kpt_map: px, py = kpt_map[i] px = min(max(px / w, 0.0), 1.0) py = min(max(py / h, 0.0), 1.0) parts.extend([f"{px:.6f}", f"{py:.6f}", "2"]) else: parts.extend(["0.000000", "0.000000", "0"]) lines.append(" ".join(parts)) return lines

这段代码里几个关键点值得说明。prefix.split(SEPARATOR)[0]是为了处理带编号的框 label,比如person_1取person去查类别索引。关键点坐标做了截断,可见性画了就填 2、没画填 0。缺失点补三个零值,保证每行长度一致。这些处理都是前面设计思路的落地。

3.4 批量处理与统计输出

批量处理就是遍历目录、调用转换、写文件、统计。统计部分我单独写,因为它是数据质量的晴雨表:

def batch_convert(json_dir, out_dir): os.makedirs(out_dir, exist_ok=True) stats = {"total": 0, "ok": 0, "image_missing": 0, "size_mismatch": 0, "no_rect": 0} for name in os.listdir(json_dir): if not name.endswith(".json"): continue stats["total"] += 1 data, err = load_and_validate(os.path.join(json_dir, name)) if err: stats[err] += 1 continue lines = convert_one(data) if not lines: stats["no_rect"] += 1 continue out_path = os.path.join(out_dir, name.replace(".json", ".txt")) with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) stats["ok"] += 1 print(stats) return stats

跑完打印的统计里,ok是成功数,其他几个是各类异常数。我一般要求ok占比 95% 以上才继续训练,低于这个值先回去查标注。no_rect表示图里只有点没有框,这种样本对关键点检测没用,直接跳过是对的。

3.5 转换结果的抽样验证

脚本跑完不能直接拿去训练,要抽样验证。我的验证方法很土但有效:随机抽 5 个 txt,把里面的归一化坐标乘回图像宽高,用 Pillow 在原图上画点和框,肉眼看位置对不对。这一步能发现归一化方向搞反、坐标轴搞混这类低级但致命的错误。验证代码不复杂,画完存成图片对比即可。我还会检查 txt 的行数是否等于该图的目标数,以及每行的字段数是否等于5 + 3 * 关键点数,字段数不对说明映射表或补零逻辑有问题。

4. 常见问题与排查技巧实录

转换脚本写起来不难,难的是数据出问题时快速定位。这一部分整理我实际遇到过的典型问题和排查路径,做成速查表,遇到问题先对表。

4.1 训练 loss 不降反升的排查顺序

这是最让人抓狂的症状。我的排查顺序是:先看标注转换,再看数据划分,最后才怀疑模型。标注转换方面,重点查三件事:关键点顺序是否和kpt_shape一致、可见性标志是否合理、归一化是否用了正确的宽高。我遇到过一次 loss 震荡,最后发现是映射表里两个点的索引写反了,模型学到的语义是乱的,改回来立刻正常。可见性方面,如果所有点都填 2,模型学不到遮挡信息,遇到遮挡样本表现会差,但一般不会导致 loss 不降,所以优先级放后面。

数据划分方面,检查训练集和验证集是否有重复图片,或者验证集里全是难样本。这个用文件名的哈希去重就能查。模型方面反而最后查,因为关键点检测的模型结构相对成熟,配置对了很少出问题。

4.2 坐标越界与负宽高的处理

坐标越界前面提过,截断加日志。负宽高是框角点顺序问题,min/max 排序解决。还有一种情况是框的宽或高归一化后等于 0,说明标注时框画成了一条线,这种样本要跳过,因为宽高为 0 的框在训练时会引发除零或者无效 anchor。我在转换函数里加了一个判断,bw或bh小于一个极小值(比如 1e-6)就跳过该目标并记录。

4.3 多目标场景的归属混乱

前面讲过用编号解决,这里补充一个排查技巧:如果怀疑点归属错了,把每个框和它配到的点用不同颜色画出来,一眼就能看出哪个点跑到了别的框下面。归属错误的典型表现是某个框的关键点数量异常多,另一个异常少。统计每个框配到的点数分布,偏离预期值的就是可疑样本。

4.4 常见问题速查表

问题现象可能原因排查方法解决方式
训练报维度错误映射表长度与 kpt_shape 不一致打印两者长度对比统一引用同一配置
loss 不下降关键点索引错位抽样画点核对语义修正映射表
归一化后坐标大于 1标注点超出图像边界检查原始坐标截断并记录日志
宽高为负框角点顺序反了打印原始角点min/max 排序
找不到图片相对路径基准错打印拼接后路径以 JSON 目录为基准
尺寸不一致图片被替换比对 JSON 与 PIL 尺寸跳过并记录
某框点数异常多目标归属错按颜色画点核对标注加编号

这张表我贴在项目文档里,新人接手先看表,能解决八成问题。

4.5 我踩过的两个印象深刻的坑

第一个坑是编码问题。labelme 的 JSON 里如果有中文标签,用默认编码读在某些环境下会报错,统一用utf-8读写就没事。第二个坑是浮点数格式化,早期我用str(round(x, 6)),结果0.5会输出成0.5而不是0.500000,虽然数值一样,但某些严格的解析器对字段格式敏感,后来统一用%.6f格式化,字段宽度一致,解析更稳。这两个坑都不大,但排查起来费时间,写在这里给后来人省点事。

4.6 转换流程的复用与扩展建议

这套流程我后来扩展到了手部关键点项目,只改了映射表和关键点数量,其他逻辑原样复用,说明设计上把可变部分收敛到配置里是对的。如果以后要支持左右手区分,可以在映射表里加前缀,或者拆成两个类别。再进一步,如果标注端换成别的工具,只要写一个适配层把它的输出转成 labelme 的 JSON 结构,后面的转换逻辑完全不用动。这种分层思路是我做数据管线时一直坚持的:格式适配层和业务转换层分开,换工具不换逻辑。

最后分享一个我个人的习惯:每次转换完,把统计结果和抽样验证图存到一个带日期的文件夹里,数据更新时对比前后统计,能快速发现新数据引入的问题。这个习惯看起来繁琐,但在数据量上来之后,是保证训练稳定的重要一环。关键点检测的成败,很多时候不在模型,而在这些看起来枯燥的数据细节里。

RELATED READING

延伸阅读

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