
做关键点检测项目的人大多绕不开一个尴尬的环节标注工具和训练框架各说各话。我最近在整理一套自用的姿态关键点数据流程标注端一直用 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 小于 x2labelme 画框时如果从右下往左上拖存下来的点顺序可能是反的所以计算前先对两个角点做 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 导出的 JSONlabels_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, encodingutf-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_okTrue) 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, encodingutf-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 结构后面的转换逻辑完全不用动。这种分层思路是我做数据管线时一直坚持的格式适配层和业务转换层分开换工具不换逻辑。最后分享一个我个人的习惯每次转换完把统计结果和抽样验证图存到一个带日期的文件夹里数据更新时对比前后统计能快速发现新数据引入的问题。这个习惯看起来繁琐但在数据量上来之后是保证训练稳定的重要一环。关键点检测的成败很多时候不在模型而在这些看起来枯燥的数据细节里。