news 2026/10/9 8:42:00

X-AnyLabeling标注转YOLO-POSE训练格式:关键点归一化与可视化校验

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
X-AnyLabeling标注转YOLO-POSE训练格式:关键点归一化与可视化校验

上个月给一个姿态估计项目喂数据,被X-AnyLabeling导出的一堆JSON搞得头大。标注界面里看着关键点都稳稳落在人身上,可一跑YOLO-POSE训练,损失直接飞上天。后来才发现问题不在模型,而在JSON转txt那一步——X-AnyLabeling记录的坐标是像素坐标,YOLO-POSE要的是归一化后的关键点坐标,还得带visibility标志位。这篇文章就把我整理的一套转换脚本和可视化校验方法分享出来,支持指定标签转换,专治标注完不知道如何喂给模型的尴尬。

无论你是刚开始做人体姿态估计,还是已经从关键点标注一路踩到训练环节,这篇内容都值得看一下。我会先把JSON和txt的格式掰开揉碎,再给一个能直接跑的转换脚本,最后说说怎么可视化检查,避免对着数字瞎瞪眼。

1. 标注产物与训练格式的差距:先搞懂JSON和txt各自长什么样

很多人在这一步翻车,不是因为不会写脚本,而是没搞懂两边数据格式到底差在哪。我最早就是把X-AnyLabeling导出的JSON直接丢给训练脚本,结果各种报错,后来才老老实实把格式梳理清楚。

1.1 X-AnyLabeling导出的JSON有哪些关键字段

X-AnyLabeling的JSON结构延续了LabelMe风格,顶层通常有这几个字段:

  • imagePath:对应原图的文件名。
  • imageWidth、imageHeight:标注时图片的宽高,单位是像素。
  • shapes:一个数组,里面有所有标注对象。

每个shape里最重要的字段是label、points、shape_type。label是你在标注时给对象起的名字,比如person,也可能带有类别前缀;points是坐标数组,比如矩形框就是两个角点,点标注就是一个坐标点;shape_type则告诉你这个对象到底是矩形、多边形还是点。

举个例子,一个典型的点标注shape长这样:

{ "label": "person:nose", "points": [[512.0, 331.0]], "shape_type": "point" }

而一个矩形框shape长这样:

{ "label": "person", "points": [[320.0, 100.0], [680.0, 500.0]], "shape_type": "rectangle" }

注意这里的坐标单位是像素,而且坐标系是从图片左上角开始算的。这个常识太容易被忽略,后面做归一化时一旦搞错坐标原点,所有关键点都会歪掉。

1.2 YOLO-POSE的txt标注格式与归一化规则

YOLO-POSE(以Ultralytics的实现为例)的每行标注格式是:

cls_id x1 y1 x2 y2 kpt1_x kpt1_y kpt1_vis kpt2_x kpt2_y kpt2_vis ...

其中:

  • cls_id:类别ID,对应你的类别映射表。
  • x1 y1 x2 y2:整个实例的边界框,用归一化后的左上角和右下角坐标表示。
  • 后面每3个数字代表一个关键点:x、y是归一化坐标,vis是可见性标志位。

举个例子,一行真实标注可能是:

0 0.213 0.345 0.678 0.789 0.321 0.456 2 0.298 0.386 2 ...

这里的0是person的类别ID,接着的4个小数是bbox,再往后每3个数字对应一个关键点。

关键点归一化必须用像素坐标除以图片宽高,得到0到1之间的相对坐标。这跟普通的图像归一化不一样,不是减去均值再除以方差,就是把坐标压缩到比例空间。原因很直接:训练时图片会被缩放到固定尺寸,如果标注还保留原始像素坐标,模型根本对不上。

可见性标志位在Ultralytics里通常有三种取值:0表示该点未标注,模型训练时会忽略;1表示点有遮挡但仍可辨别;2表示点完全可见。假如你的数据里没有遮挡标注习惯,最稳妥的做法是:已标注的点填2,缺失的点填0。

1.3 转换本质:把“人的逻辑”翻译成“模型的语言”

JSON里记录的关键点顺序可能是乱的,也可能一个实例只标了部分点;但YOLO-POSE要求关键点顺序固定,数量固定。这就是转换要解决的核心问题:筛选出需要的目标,按固定的关键点顺序补全,把所有像素坐标归一化到0到1之间。

我用一个很朴素的比喻理解这事:JSON是标注入用自然语言写下的笔记,txt是模型用固定表格填写的答题卡。笔记可以随手写,想到哪写到哪;答题卡必须一格一空整齐填完。转换脚本干的就是誊写的活儿,誊错一格,模型就会读错一个位置。

`s>如果这个环节不做可视化验证,你根本不知道誊写时抄错了哪一格。这也是为什么我在转换脚本之外一定要配一个可视化脚本,下面会专门讲。

2. 支持指定标签转换:多类别关键点任务的映射方案

标题里特别强调“可指定标签转换”,这点太实用了。我做第一个版本时没考虑这个需求,结果标注里有用的class和没用的临时标签混在一起,转换出来的txt多处一串脏数据,训练损失曲线直接飙到天际。

2.1 为什么不能把所有标注一股脑导出去

如果你的标注文件里只有person一个类别,那怎么导都行。但实际项目中经常出现这种情况:标注员为了测试标了几个dog、cat,或者在调试时随手标了一个test标签。如果不加筛选,这些杂质就会进入训练集。

问题是,YOLO-POSE并不会因为你给了未知标签就报错,它只会把这些标签转换成ID,然后静默处理。等到精度评测的时候,你会发现某些验证图片上的关键点错乱至极。排查半天才找到原因:训练集中的标签ID和验证集不一致。

转换脚本支持--labels参数后,你只需要指定“这次训练要哪些标签”,其他全部忽略。这相当于给数据管道加了一层筛子。

2.2 类目映射表与可见性标志位设计

类目映射表的设计也很关键。我习惯在脚本顶部维护一个字典:

LABEL_MAP = { "person": 0, "cat": 1, "dog": 2, }

这表示标注里的person对应txt里的类别ID 0,cat对应1,dog对应2。如果两个不同标注名想映射到同一个ID,直接把字典值设成同一个数字就行。

对于可见性标志位,我统一约定:缺失关键点坐标填0,可见性填0;已标注关键点坐标照填,可见性填2。这样处理的最大好处是,模型读到的每一行长度完全一致,不会因为某些点缺失导致数据维度错乱。

如果后续你想用遮挡信息,可以把可见性改为1。但注意Ultralytics的损失计算逻辑会区别对待,先跑通版本用0和2最省心。

2.3 处理“部分关键点缺失”的常见策略

姿态标注最烦的情况是:一个实例被遮挡了一半,某些关键点根本没标。这时候如果转换脚本遇到缺失点就跳过,生成的txt就会忽长忽短,训练直接崩。

我的做法是提前定义一份“关键点姓名单”,类似于:

KEYPOINT_NAMES = [ "nose", "left_eye", "right_eye", "left_ear", "right_ear", "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", "left_wrist", "right_wrist", "left_hip", "right_hip", "left_knee", "right_knee", "left_ankle", "right_ankle" ]

转换时先给每个实例建一个空字典,然后遍历所有标注点,按名字塞进对应位置。最后输出时,按KEYPOINT_NAMES的顺序依次取点,取到就填坐标和2,没取到就填0和0。

这样就保证了每个实例输出固定17组关键点数据,哪怕实际只标了5个点,txt里也会补齐为17组。模型训练时会对可见性为0的点自动忽略,不会影响整体梯度计算。

3. JSON转txt完整实现:一个开箱即用的Python脚本

下面这个脚本是我在项目里用了一整轮的版本,支持批量处理、指定标签转换,还会打印跳过信息。你可以直接复制改成自己的路径。

3.1 脚本设计与依赖选择

转换本身只用了Python标准库,不需要额外安装依赖。可视化校验才需要OpenCV,所以我把转换脚本和可视化脚本分开写。转换脚本的定位是“无脑跑完一个标注目录”,尽量安静、稳定。

主要设计点:

  • 通过命令行参数传入json_dir和out_dir,不硬编码路径。
  • 通过--labels指定要转换的标签列表。
  • 通过--label_map自定义类别ID映射。
  • 每个JSON文件对应输出一个同名txt,不合并。

3.2 核心代码:解析、筛选、归一化、写出

先看完整代码,我会在后面拆解关键部分。

import json import os import argparse from collections import defaultdict COCO_KEYPOINT_NAMES = [ "nose", "left_eye", "right_eye", "left_ear", "right_ear", "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", "left_wrist", "right_wrist", "left_hip", "right_hip", "left_knee", "right_knee", "left_ankle", "right_ankle" ] def parse_args(): parser = argparse.ArgumentParser(description="X-AnyLabeling JSON to YOLO-POSE txt") parser.add_argument("--json_dir", required=True, help="标注JSON所在目录") parser.add_argument("--out_dir", required=True, help="输出txt目录") parser.add_argument("--labels", nargs="+", default=None, help="只转换指定标签,例如 --labels person cat") parser.add_argument("--label_map", nargs="*", default=None, help="自定义标签到类别ID映射,例如 --label_map person=0 cat=1") return parser.parse_args() def build_label_map(args): default_map = {"person": 0} if args.label_map: for item in args.label_map: key, value = item.split("=") default_map[key.strip()] = int(value.strip()) return default_map def norm_coord(value, max_val): value = float(value) if max_val <= 0: return 0.0 result = value / max_val return max(0.0, min(1.0, result)) def convert_one_json(json_path, out_path, label_map, labels): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) img_w = data.get("imageWidth", 0) img_h = data.get("imageHeight", 0) if not img_w or not img_h: print(f"[跳过] {json_path}: 缺少imageWidth或imageHeight") return False shapes = data.get("shapes", []) bbox_by_label = {} kpt_by_label = defaultdict(dict) for shape in shapes: label = shape.get("label", "") shape_type = shape.get("shape_type", "") points = shape.get("points", []) if not points: continue if shape_type == "rectangle": x0, y0 = points[0] x1, y1 = points[1] bbox_by_label[label] = [ min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1) ] else: # 关键点标注:label建议写成 "类别:关键点名称" if ":" not in label: continue cls_name, kpt_name = label.split(":", 1) if kpt_name not in COCO_KEYPOINT_NAMES: continue x, y = points[0] kpt_by_label[cls_name][kpt_name] = (x, y) lines = [] for cls_name in kpt_by_label: if labels and cls_name not in labels: continue if cls_name not in label_map: print(f"[跳过] {json_path}: 标签 {cls_name} 不在映射表中") continue cls_id = label_map[cls_name] kpt_dict = kpt_by_label[cls_name] if cls_name in bbox_by_label: x1, y1, x2, y2 = bbox_by_label[cls_name] else: xs = [pt[0] for pt in kpt_dict.values()] ys = [pt[1] for pt in kpt_dict.values()] if not xs: continue x1, x2 = min(xs), max(xs) y1, y2 = min(ys), max(ys) bbox = ( norm_coord(x1, img_w), norm_coord(y1, img_h), norm_coord(x2, img_w), norm_coord(y2, img_h) ) parts = [str(cls_id)] parts.extend([f"{v:.6f}" for v in bbox]) for kpt_name in COCO_KEYPOINT_NAMES: if kpt_name in kpt_dict: kx, ky = kpt_dict[kpt_name] parts.append(f"{norm_coord(kx, img_w):.6f}") parts.append(f"{norm_coord(ky, img_h):.6f}") parts.append("2") else: parts.extend(["0.000000", "0.000000", "0"]) lines.append(" ".join(parts)) if not lines: print(f"[跳过] {json_path}: 没有可输出的有效标注") return False with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") return True def main(): args = parse_args() os.makedirs(args.out_dir, exist_ok=True) label_map = build_label_map(args) total = 0 success = 0 for name in sorted(os.listdir(args.json_dir)): if not name.endswith(".json"): continue json_path = os.path.join(args.json_dir, name) out_name = name.replace(".json", ".txt") out_path = os.path.join(args.out_dir, out_name) total += 1 if convert_one_json(json_path, out_path, label_map, args.labels): success += 1 print(f"处理完成: 共{total}个JSON, 成功生成{success}个txt") print(f"注意: 如果某个JSON被跳过, 请检查上面的[跳过]日志") if __name__ == "__main__": main()

这段代码有几个细节值得说明。

首先是解析标注点时,我默认标注者把关键点label写成类别:关键点名称的格式。比如要标注person的鼻子,label就是person:nose。如果你的标注习惯不同,比如用下划线person_nose,把解析部分改成按_拆分即可。

其次是bbox来源:如果JSON里有矩形框,优先用矩形框作为实例边界框;如果没有矩形框,就从所有已标注关键点的坐标里取最小和最大,拼出一个包围盒。这样兼容了两种标注习惯。

最后是归一化的边界处理:norm_coord函数里把结果强制限制在0到1之间,防止某些坐标因为标注越界导致输出超过1,进而让模型训练出现NaN。

3.3 运行方式与输出示例

假设你的X-AnyLabeling标注文件在./annotations,想输出到./labels,并且只转换person和cat,运行:

python convert_xal_to_yolo_pose.py --json_dir ./annotations --out_dir ./labels --labels person cat

如果默认映射正好是person=0,那--label_map可以不传。假如你希望cat的ID是2,dog的ID是3,就加参数:

python convert_xal_to_yolo_pose.py --json_dir ./annotations --out_dir ./labels --label_map person=0 cat=2 dog=3 --labels person cat dog

转换成功后,一个txt文件里可能出现多行,每行代表一个实例。比如某个图片里有两个person,就会输出两行,第一行的类别ID和坐标是第一个人,第二行是第二个人。

3.4 批量处理整个标注目录

脚本的主函数遍历json_dir下所有以.json结尾的文件,按同名规则输出txt。这样做的好处是后续做数据集划分时,可以直接根据文件名关联原图和txt。

你需要特别注意:如果标注文件里没有任何有效关键点,脚本会跳过并打印日志,但不会生成空的txt。这是故意的,因为空txt在YOLO训练时会被当成“背景图”,而实际上你可能只是标漏了。日志会提示你去检查。

我在实际使用中还会加一个统计逻辑:统计每个类别的实例数量、关键点缺失情况。不过为了保持脚本简单,这里先不展开,后面会讲自动化校验。

4. 关键点可视化检查:让坐标问题肉眼可见

转换完txt后,最忌讳的就是直接开始训练。我第一轮就是跳过可视化,结果训练出来的模型关键点全偏到天上。后来老老实实写了可视化脚本,才发现问题出在转换阶段的坐标错位。可视化这一步真的省下很多无效训练时间。

4.1 用OpenCV叠加标注和原图

可视化脚本的核心是:读入原图,读入txt标注,把归一化坐标乘回图像宽高,然后用OpenCV画出来。

import cv2 def draw_yolo_pose(img, parts, keypoint_names): h, w = img.shape[:2] cls_id = int(parts[0]) x1, y1, x2, y2 = [ float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) ] x1 = int(x1 * w) x2 = int(x2 * w) y1 = int(y1 * h) y2 = int(y2 * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2, lineType=cv2.LINE_AA) kpt_numbers = parts[5:] for i, name in enumerate(keypoint_names): kx = float(kpt_numbers[i * 3]) * w ky = float(kpt_numbers[i * 3 + 1]) * h vis = int(float(kpt_numbers[i * 3 + 2])) color = (0, 0, 255) if vis > 0 else (128, 128, 128) cv2.circle(img, (int(kx), int(ky)), 3, color, -1, lineType=cv2.LINE_AA) cv2.putText(img, str(i), (int(kx) + 3, int(ky) - 3), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 0), 1) return img

这一步能立刻看出坐标是不是画在人物身上。如果点全部缩在左上角,说明归一化除错了,大概率是把imageWidth和imageHeight搞反了。

4.2 骨架连线与关键点ID标号

光画点还不够,点的顺序对不对很难靠肉眼判断。我的办法是把相邻关键点连成骨架线,这样一眼就能看出姿态自然不自然。COCO 17的关键点骨架连接关系大致是:

SKELETON = [ (0, 1), (0, 2), (1, 3), (2, 4), (5, 6), (5, 7), (7, 9), (6, 8), (8, 10), (5, 11), (6, 12), (11, 13), (13, 15), (12, 14), (14, 16), (11, 12) ]

在绘制函数里加上连线逻辑:

for a, b in SKELETON: if int(float(kpt_numbers[a * 3 + 2])) > 0 and int(float(kpt_numbers[b * 3 + 2])) > 0: ax = int(float(kpt_numbers[a * 3]) * w) ay = int(float(kpt_numbers[a * 3 + 1]) * h) bx = int(float(kpt_numbers[b * 3]) * w) by = int(float(kpt_numbers[b * 3 + 1]) * h) cv2.line(img, (ax, ay), (bx, by), (255, 0, 0), 1, lineType=cv2.LINE_AA)

连上之后,如果某个关键点的顺序写错了,骨架线会诡异交叉。比如鼻子和左眼连错,画出来就是一条从额头拉到胸口的斜线,肉眼极容易发现。

4.3 可视化结果能发现哪几类典型错误

我总结了一张问题对照表,基本覆盖了我踩过的坑:

可视化现象可能原因处理方式
所有关键点挤在图片左上角归一化时除以了错误的宽高检查imageWidth和imageHeight字段
关键点位置正确但bbox偏移矩形框归一化时用了关键点的坐标范围确保bbox用矩形框数据,而不是关键点包络
骨架连线交叉明显关键点顺序与KEYPOINT_NAMES不一致统一标注命名和脚本顺序
部分点消失(灰色)该关键点未标注,vis为0确认是否漏标,或被遮挡
原图上没有任何标注txt类别ID或坐标越界检查txt是否为空,检查坐标裁剪逻辑

这张表我打印出来贴在显示器边,每次转换完随机抽几张图看一眼,确保没有以上现象再进入训练。

5. 落地过程中的踩坑清单与效率优化

到了这一步,大部分读者已经能把转换和可视化跑通。但真正让这个流程稳定的,是接下来这堆细节。每一条都是我实打实踩出来的坑,写出来帮你躲开。

5.1 最容易错的三个坐标细节

第一个坑是图片尺寸字段丢失。有些X-AnyLabeling版本导出的JSON里imageWidth和imageHeight可能是0。我的建议是转换前写一个预处理脚本,从原图读取真实尺寸补进JSON。千万不要默认一定存在。

第二个坑是坐标越界。标注员在图片边缘打点时,鼠标可能略出画布,导致关键点坐标大于图宽或高。如果不做裁剪,归一化后会出现大于1的值,模型训练直接就崩。我在norm_coord里做了max(0, min(1, ...))强制截断,但更稳妥的办法是在标注阶段就约束点不能超出图像。

第三个坑是矩形框两个角点的顺序。X-AnyLabeling画矩形框时,用户可能从左上拖到右下,也可能从右下拖到左上。脚本里手动做了min和max,这样不管哪种拖法都能得到正确的左上、右下坐标。

5.2 类别ID不一致导致的训练精度暴跌

训练配置文件data.yaml里的names列表顺序,必须和txt里的类别ID一一对应。最典型的错误是data.yaml里写的是:

names: 0: person 1: cat

但转换脚本里的LABEL_MAP却是person:0, dog:1, cat:2。这时训练不会报错,但每个类别的ID全错位了,精度自然会暴跌。

我的建议是:在转换脚本里输出一个meta.json,记录实际用到的标签和ID。训练前先检查这个文件,而不是靠脑子记。比如转换后自动生成:

{ "labels_used": ["person", "cat"], "label_to_id": {"person": 0, "cat": 2}, "instance_count": {"person": 128, "cat": 34} }

这样如果data.yaml和这个文件对不上,一眼就能发现。

5.3 给转换工具加点保险:自动校验与错误报告

批量处理几百个JSON时,如果只靠控制台日志,很容易漏掉中间某个文件的错误。我习惯在脚本里加一个validate函数,对生成的每个txt做三件事:

  • 检查每行是否以数字开头,后面数字数量是否为1 + 4 + 17 * 3。
  • 检查所有坐标是否都在0到1之间。
  • 检查可见性标志是否为0、1、2三者之一。

如果条件不满足,就把文件名和错误原因写进error_report.txt。这一步看起来简单,但能拦截90%的静默错误。

另外我还会在转换完成后随机抽3到5张图做可视化检查,而不是全部检查。全部检查太耗时,抽几张基本就能发现系统性问题。如果是小数据集,建议全查。

5.4 后续可以考虑的扩展

转换流程稳定之后,还可以延伸出几个方向:

  • 自动划分训练集和验证集。基于文件名按比例分割,生成train.txt和val.txt。
  • 在可视化脚本中加入保存按钮,方便把错误样例截图记录。
  • 如果你用的是COCO格式,也可以在这个基础上再写一个转COCO JSON的脚本,未来做mmpose训练时直接复用。

这些扩展不复杂,核心逻辑都是围绕“标注数据怎么标准化”这个主题。把基础转换和校验做好,后续替换模型框架时,只需要改输出格式那一小段。

最后分享一个小技巧:我第一次跑完整套流程后,发现很多问题是标注阶段就埋下的,比如label命名不统一、有的标person:nose有的标person_nose。后来我专门写了一段检查脚本,扫描所有JSON的label字段,把不规范的命名全部列出来,在转换前就统一掉。建议你也先做这一步,能省掉后面大量返工。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 8:41:26

DC4靶机完整渗透实战:弱口令爆破、命令注入到sudo提权全解析

1. 动手之前&#xff1a;环境、目标与整体思路1.1 靶场怎么搭、Kali怎么配把DC4这台机器从头到尾打一遍&#xff0c;是我最近一次比较解压的靶机练习。DC系列在VulnHub上出了非常多台&#xff0c;DC4属于中间难度偏温和的一台&#xff0c;不需要反编译、不需要二进制漏洞&#…

作者头像 李华
网站建设 2026/10/9 8:40:04

三电平NPC逆变器SPWM仿真入门:从原理到模型搭建

三电平NPC逆变器是我这几年做新能源并网、电机驱动项目里最常用的拓扑之一。很多人第一次接触“三电平NPC-SPWM仿真”这个组合时&#xff0c;总觉得门槛高&#xff1a;既要知道NPC钳位原理&#xff0c;又要会SPWM调制&#xff0c;还得把仿真模型跑得稳定不发散。以我的经验&…

作者头像 李华
网站建设 2026/10/9 8:37:45

AI时代,文档型PM与CRUD码农如何破局?转型路径全拆解

1. 裁员名单出来之前&#xff0c;其实早有信号前几天一个做HR的朋友给我看了一份内部优化名单&#xff0c;我扫了一眼&#xff0c;心里咯噔一下。名单上一半是工作五六年以上的项目经理&#xff0c;另一半是技术栈看起来很"稳定"的后端开发。他们有一个共同特征&…

作者头像 李华
网站建设 2026/10/9 8:36:20

SpringBoot调查问卷系统实战:从数据库设计到Docker部署全解析

拿SpringBoot做一套调查问卷系统&#xff0c;听起来是标准的CRUD模板题&#xff0c;真正动手做才发现&#xff0c;坑全藏在“问卷”这两个字里&#xff1a;题型五花八门、答卷防重、统计分析、定时回收&#xff0c;哪一环单拎出来都够写一篇长文。这篇文章我想从一个已经落地的…

作者头像 李华
网站建设 2026/10/9 8:34:34

管家婆辉煌版7.1A实操指南:进销存与账务处理核心技巧

1. 从一张手工台账说起&#xff1a;为什么还要折腾这套老系统前阵子帮一个做建材批发的老朋友整理账目&#xff0c;他翻出三本手写台账&#xff0c;进货、出货、欠款全混在一起&#xff0c;月底对账时发现有两笔八千多的货款怎么都对不上。他问我有没有什么办法能让账目清楚一点…

作者头像 李华