做关键点检测项目的人多半都经历过这个瞬间:在 labelme 里辛辛苦苦点完几十张图的关键点,导出 JSON 一看,信心满满地丢给 YOLO 训练脚本,结果第一行就报错——找不到标签文件,或者找到了但格式对不上。我最初做自用关键点检测时也卡在这一步,labelme 的 JSON 结构和 YOLO 关键点检测要求的 txt 标注之间隔着一整套坐标归一化、关键点可见性编码和数据集目录约定的转换逻辑。这篇就把我自己那套 labelme2yolo 格式转换的完整做法拆开讲清楚,包括坐标怎么算、可见性标志怎么定、目录怎么摆、脚本怎么写,以及我踩过的几个不写出来你大概率也会踩的坑。适合已经会用 labelme 打点、准备把数据喂给 YOLO 关键点检测模型的自用开发者,也适合想搞清楚两种标注格式差异的读者。
1. 先搞清楚两种格式到底差在哪
动手写转换脚本之前,必须先把 labelme 的输出和 YOLO 关键点检测的输入这两套格式逐字段对齐。很多人转换失败不是因为代码写错,而是压根没弄清两边各存了什么、缺了什么。我见过最常见的误解是以为"都是关键点,坐标搬过去就行",结果训练时 loss 一直不降,回头查才发现关键点顺序和可见性全乱了。
1.1 labelme 的 JSON 里究竟存了什么
labelme 保存的是一份 JSON,核心结构大致是这样:顶层有imagePath、imageWidth、imageHeight、imageData(可选,取决于保存时是否内嵌图像),然后是shapes数组。每个 shape 里有label(标签名)、points(坐标点数组)、shape_type(点、矩形、多边形等)、group_id等字段。
关键点标注场景下,shape_type通常是point,每个 shape 的points里只有一个[x, y]。也就是说,一张图上有几个关键点,就有几个独立的 shape,每个 shape 的label就是那个关键点的名字,比如nose、left_shoulder、right_hip之类。这里有个容易被忽略的点:labelme 的坐标是像素绝对坐标,原点在图像左上角,x 向右、y 向下,和大多数图像处理库一致。
还有一个细节值得单独说:labelme 的 JSON 里,imageWidth和imageHeight是判断坐标是否越界、归一化是否正确的重要依据。有些版本在未内嵌图像时这两个字段可能缺失或为 0,转换脚本必须做兜底,否则归一化时除以 0 直接崩。我一般会在脚本里优先读这两个字段,读不到就用图像库打开原图取尺寸,双保险。
1.2 YOLO 关键点检测的 txt 长什么样
YOLO 的关键点检测标注是每张图一个 txt,每行描述一个目标实例。一行的结构是:类别索引、目标框中心 x、目标框中心 y、目标框宽、目标框高,这五个是归一化到 0~1 的浮点数;紧接着是 K 组三元组,每组是x, y, v,K 是关键点总数,x、y 同样归一化到 0~1,v 是可见性标志(0 表示未标注/不可见,1 表示标注了但被遮挡,2 表示可见且未遮挡)。
这里就是第一个大坑:YOLO 关键点格式要求每个实例都带一个目标框,而 labelme 打点往往只标了关键点,没有框。自用场景下通常一张图只有一个目标(比如一个人、一只手),那这个框就得自己从关键点坐标里推出来——取所有关键点的最小外接矩形,再适当外扩一点。如果一张图有多个目标实例,那还得靠group_id或标签命名约定把关键点分组,这部分复杂度陡增,自用项目里我一般直接约定"一图一实例",省掉分组逻辑。
第二个坑是关键点顺序必须全局固定。YOLO 训练时是按索引对齐关键点的,第 0 个三元组永远对应你定义的第一个关键点。labelme 里 shape 的排列顺序是标注时的点击顺序,完全不可靠。所以转换脚本里必须维护一份固定的关键点名称列表,按这个列表的顺序去 JSON 里查找对应 label 的坐标,找不到就填0 0 0。这份列表的顺序一旦定下就不能改,改了就得重新转换全部数据。
| 对比项 | labelme JSON | YOLO 关键点 txt |
|---|---|---|
| 存储单位 | 每图一个 JSON | 每图一个 txt |
| 坐标形式 | 像素绝对坐标 | 归一化 0~1 |
| 关键点组织 | shapes 数组,顺序为点击顺序 | 固定索引顺序的三元组 |
| 可见性 | 无显式字段 | v 值 0/1/2 |
| 目标框 | 需另画矩形 shape | 每行必带,归一化 cx cy w h |
| 类别信息 | label 字符串 | 类别索引整数 |
2. 转换脚本的核心逻辑拆解
格式对齐之后,脚本的逻辑其实不复杂,但每一步都有讲究。我把自己那版脚本拆成四个环节:读 JSON 取尺寸、按固定列表收集关键点、推算目标框、归一化并写行。下面逐个说,顺带把每步为什么这么写讲透。
2.1 关键点名称列表与索引映射
脚本开头第一件事是定义关键点列表,比如做一个手部关键点检测,就定义 21 个点的名字,顺序按你训练时要用的语义顺序来,比如从手腕开始、按手指依次排。这个列表同时决定了输出 txt 里三元组的排列,也决定了模型输出通道的语义。
KEYPOINT_NAMES = [ "wrist", "thumb_cmc", "thumb_mcp", "thumb_ip", "thumb_tip", "index_mcp", "index_pip", "index_dip", "index_tip", # ... 其余手指 ] NAME_TO_IDX = {name: i for i, name in enumerate(KEYPOINT_NAMES)}用字典做名称到索引的映射,是为了后面在 shapes 里快速定位。这里有个实操建议:labelme 里打点时标签名一定要和这份列表完全一致,大小写、下划线都不能差。我吃过一次亏,labelme 里手滑写成Thumb_Tip,脚本里是thumb_tip,结果那个点永远填 0,训练出来的模型那个指尖位置一直飘,查了大半天才发现是标签名不匹配。稳妥做法是在脚本里加一条告警:JSON 里出现列表之外的 label 时打印出来,方便及时发现拼写问题。
2.2 从关键点反推目标框的几种策略
前面说了 YOLO 每行必须带框,而 labelme 只标点时没有框。反推框有几种策略,各有适用场景。
第一种是关键点最小外接矩形直接当框。取所有有效关键点的 min x、max x、min y、max y 组成矩形。优点是简单、和关键点严格对应;缺点是如果关键点分布集中(比如只标了脸部几个点),框会偏小,模型学到的目标范围偏窄。
第二种是外接矩形按比例外扩。在第一种基础上,宽高各放大一定比例,比如 1.2 倍,中心不变。这个比例需要根据你的目标类型调:手部这种关键点基本覆盖整个目标的,1.1~1.2 就够;人体这种关键点集中在骨架、四肢末端离轮廓还有距离的,可能要 1.3~1.5。我一般先取 1.2 跑一版,看验证集上框的召回情况再微调。
第三种是labelme 里同时画了矩形框。如果你打点时顺手画了包围框(shape_type 为 rectangle),那直接读那个框最准,脚本里优先找 rectangle 类型的 shape,找不到再用关键点反推。这是最推荐的做法,多花几秒画个框,省掉后面调外扩比例的麻烦。
外扩之后还要做边界裁剪,把框限制在图像范围内,否则归一化后出现负数或大于 1 的值,训练时可能报错或产生异常梯度。裁剪逻辑就是x1 = max(0, x1)、x2 = min(W, x2)这一套,别嫌琐碎。
2.3 归一化与可见性标志的确定
归一化本身是一行除法:x_norm = x_pixel / image_width,y_norm = y_pixel / image_height。但要注意两点:一是必须用同一张图的宽高,别拿错图;二是浮点精度,输出时保留 6 位小数足够,YOLO 读取时对精度不敏感,但保留太少(比如 2 位)在关键点密集时会有明显偏差。
可见性 v 值的确定是自用场景里最容易被糊弄过去的一环。严格来说,v=2 表示可见未遮挡,v=1 表示标注了但被遮挡,v=0 表示未标注。labelme 打点时你其实无法直接表达"遮挡"这个语义,除非用不同标签或额外字段。我的做法是:只要这个关键点在 JSON 里存在,就填 v=2;不存在就填0 0 0(坐标和 v 全 0)。这在自用项目里够用,因为大部分数据关键点都是可见的。如果你的数据里有大量遮挡情况,建议在 labelme 里用标签后缀区分,比如nose_occ表示遮挡的鼻子,脚本里识别后缀映射成 v=1,这样模型能学到遮挡状态,效果会更好。
提示:v=0 的三元组坐标也必须是 0,不能填真实坐标配 v=0。有些实现会忽略 v=0 的坐标,但为了兼容性,统一填
0 0 0最稳。
2.4 写文件与目录组织
YOLO 数据集的标准目录结构是images/train、images/val、labels/train、labels/val,txt 文件名必须和对应图片文件名(不含扩展名)一致。转换脚本输出时就要按这个结构落盘,别先平铺再手动挪,容易漏。
import json, os from pathlib import Path def convert_one(json_path, out_label_dir, img_w, img_h): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) w = data.get("imageWidth") or img_w h = data.get("imageHeight") or img_h kps = [[0.0, 0.0, 0] for _ in KEYPOINT_NAMES] xs, ys = [], [] for shape in data["shapes"]: if shape["shape_type"] != "point": continue name = shape["label"] if name not in NAME_TO_IDX: print(f"[warn] unknown label {name} in {json_path}") continue x, y = shape["points"][0] idx = NAME_TO_IDX[name] kps[idx] = [x / w, y / h, 2] xs.append(x); ys.append(y) if not xs: return # 没有有效关键点,跳过 x1, x2 = min(xs), max(xs) y1, y2 = min(ys), max(ys) cx, cy = (x1 + x2) / 2, (y1 + y2) / 2 bw, bh = (x2 - x1) * 1.2, (y2 - y1) * 1.2 # 裁剪到图像范围 cx = min(max(cx, 0), w); cy = min(max(cy, 0), h) bw = min(bw, w); bh = min(bh, h) line = f"0 {cx/w:.6f} {cy/h:.6f} {bw/w:.6f} {bh/h:.6f}" for x, y, v in kps: line += f" {x:.6f} {y:.6f} {v}" out_path = Path(out_label_dir) / (Path(json_path).stem + ".txt") out_path.write_text(line + "\n", encoding="utf-8")这段是核心骨架,实际用的时候外面套一层遍历目录、按比例划分 train/val 的逻辑。划分时注意图片和标签要同步移动,我习惯先按文件名列表 shuffle 再切分,保证两边严格对应。
3. 我踩过的几个坑和对应解法
转换脚本跑通只是第一步,真正让训练顺利的是那些格式之外的细节。下面这几个坑都是我实际踩过、并且发现身边做同类项目的人也在踩的,写出来能帮你省不少调试时间。
3.1 关键点顺序错乱导致 loss 不降
最隐蔽的一个坑。脚本能跑、文件能生成、训练也不报错,但 loss 就是降不下去,验证时关键点位置整体偏移。原因往往是 labelme 里 shape 的点击顺序和脚本里的关键点列表顺序不一致,而脚本又偷懒按 shapes 数组顺序直接写三元组,没做名称映射。
解法就是前面说的:永远按固定名称列表映射,绝不依赖 shapes 顺序。另外建议转换完后抽几张图做可视化验证——把 txt 里的关键点按索引画回原图,和 labelme 里的点对比,一眼就能看出顺序对不对。这个验证步骤花五分钟,能省几小时排查。
3.2 图像尺寸字段缺失或与实际不符
有些 labelme 版本在保存时imageWidth、imageHeight可能为 0,或者你后期用工具裁剪过图片但 JSON 没更新,导致归一化基准错误,所有坐标整体偏移。我的处理是:以实际图像文件尺寸为准,脚本里用图像库打开原图取size,JSON 里的尺寸只作为交叉校验,两者不一致时打印告警并以图像为准。
from PIL import Image with Image.open(img_path) as im: img_w, img_h = im.size这一步多一次 IO,但换来的是数据可靠性,值得。
3.3 空标签文件和全零关键点的处理
如果某张图的关键点全被过滤掉(标签名不匹配、shape 类型不对),脚本会生成一个只有框、关键点全零的行,或者干脆空文件。空文件在训练时可能被当成"无目标"样本,影响负样本比例;全零关键点的行则会让模型学到"这张图没有关键点",污染数据。
我的做法是:没有有效关键点的图直接跳过,不生成 txt,同时把对应图片从数据集里剔除。转换脚本最后打印一份统计:总 JSON 数、成功转换数、跳过数及跳过原因,方便你回头补标。这个统计输出看似小事,实际能帮你快速发现"为什么训练集比预期少了几十张"。
3.4 多实例场景的分组难题
前面提过,自用项目我一般约定一图一实例。但如果你确实需要一图多目标(比如多人姿态),labelme 的group_id字段就是用来分组的:打点时给同一个目标的点设相同 group_id。转换脚本里按 group_id 分组,每组独立算框、独立写一行。注意 group_id 为 0 或空时通常表示未分组,需要单独处理成一组或直接跳过。这块逻辑比单实例复杂不少,建议先确认单实例流程跑通再上多实例。
| 坑点 | 现象 | 根因 | 解法 |
|---|---|---|---|
| 顺序错乱 | loss 不降、关键点整体偏移 | 依赖 shapes 点击顺序 | 固定名称列表映射 + 可视化验证 |
| 尺寸不符 | 坐标整体偏移 | JSON 尺寸缺失或过期 | 以实际图像尺寸为准 |
| 空标签 | 训练集数量对不上 | 无效关键点未过滤 | 跳过并统计原因 |
| 多实例 | 关键点串到别的目标上 | 未按 group_id 分组 | 按 group_id 分组独立成行 |
4. 转换完成后的自检清单
脚本跑完不等于数据就绪,我习惯在开训前过一遍自检清单,这几项检查能拦掉大部分"训练异常但报错不明显"的问题。
4.1 数量与命名一致性核对
先核对三组数量:JSON 文件数、生成的 txt 数、对应图片数。三者应该满足"txt 数 ≤ 图片数 = JSON 数",差值就是被跳过的样本,对照脚本打印的跳过原因确认是否合理。再抽查文件名:随机抽 10 个 txt,确认每个都能在 images 目录找到同名图片(扩展名不同),反之亦然。命名不一致是训练时报"找不到标签"的头号原因,提前核对能省事。
4.2 坐标范围与关键点可视化抽检
写个小脚本遍历所有 txt,检查每个数值是否都在 0~1 之间(v 值除外,它是 0/1/2 整数),框的宽高是否大于 0。任何越界值都说明归一化或裁剪有问题。然后抽 5~10 张图做可视化:读 txt,把框画成矩形、关键点按索引画成带编号的点,叠在原图上保存。人眼扫一遍,重点看关键点是否落在语义正确的位置、框是否合理包住目标。这一步是最后一道防线,我每次转换新数据集都会做。
4.3 数据集配置文件的关键点参数
YOLO 训练还需要一个数据集配置文件(通常是 yaml),里面除了路径,关键点检测还要声明kpt_shape: [K, 3],K 是关键点数量,3 表示每个点三个值(x、y、v)。这个数字必须和 txt 里三元组数量严格一致,写错了训练直接报维度不匹配。另外类别数nc和类别名也要对上,自用单类别就写 1 和对应名字。配置文件里路径建议用绝对路径或相对于训练脚本的路径,别用容易歧义的相对路径,我见过因为路径解析问题导致训练读到空数据集的案例。
path: /data/my_kpt_dataset train: images/train val: images/val nc: 1 names: ["hand"] kpt_shape: [21, 3]注意:
kpt_shape的 K 必须等于你 KEYPOINT_NAMES 的长度,改关键点定义时这两处要同步改,漏改一处就是维度报错。
5. 让转换流程更省心的几个习惯
这套流程我迭代了几版,最后沉淀下来几个习惯,算不上什么高深技巧,但确实让每次换数据集时少折腾很多。
第一个习惯是把关键点定义单独放一个配置文件,脚本从配置读列表,而不是硬编码在代码里。这样换项目时只改配置不改代码,也方便把同一份定义共享给训练配置和可视化脚本,避免多处维护导致不一致。
第二个习惯是转换脚本支持 dry-run 模式,只统计不写文件,先看一遍有多少样本会被跳过、跳过原因分布如何,确认没问题再真正落盘。数据量大时这个模式能避免反复生成又删除的麻烦。
第三个习惯是保留原始 JSON 和转换后 txt 的映射日志,记录每个 txt 来自哪个 JSON、用了哪张图的尺寸、跳过了哪些标签。出问题时顺着日志能快速定位是哪个环节出的岔子,比盲目重跑高效得多。
第四个习惯是版本化关键点定义。一旦用某版定义训出了可用模型,就把那份定义连同转换脚本一起存档。后面想加关键点或调整顺序时,新建一版而不是改旧的,老数据用老定义、新数据用新定义,避免"改了定义导致旧模型无法复现"的尴尬。自用项目最容易忽视这点,等到想对比两版模型效果时才发现数据定义已经悄悄变了。
这套 labelme 到 YOLO 关键点格式的转换,说到底就是把"人打点的自由格式"翻译成"模型要的严格格式",难点不在代码量,而在对两边格式细节的理解和对边界情况的处理。把名称映射、尺寸基准、框反推、可见性这几件事做扎实,剩下的就是跑起来看效果、按需微调了。