目标检测的数据标注,是每个做视觉项目的人都绕不开的一道坎。我见过太多团队在标注工具上反复横跳:有人习惯用 labelme 那种点几下就能画多边形、改点、加标签的交互,觉得顺手;也有人认准 YOLO 训练时那套一行一个框、归一化坐标的 txt 格式,觉得喂给模型最省心。结果就是,标注阶段用一套格式,训练阶段要另一套格式,中间全靠人肉转换,转一次错一次,错一次查半天。这篇就专门聊 labelme 和 YOLO 两种标注格式之间的互转,把 JSON 和 TXT 之间那点事彻底讲透,包括坐标怎么算、类别怎么映射、图片尺寸从哪来、批量脚本怎么写、以及我踩过的那些坑。不管你是刚接触目标检测的新手,还是已经跑过几个项目的老手,只要你的流程里同时出现过 labelme 和 YOLO,这篇内容都能直接拿去用。
1. 先搞清楚两种格式到底在存什么
很多人转换出错,根本原因不是脚本写错了,而是压根没弄明白这两种格式各自在表达什么。labelme 存的是"几何形状 + 语义标签",YOLO 存的是"归一化后的框 + 类别索引",两者的坐标系、精度、组织方式都不一样。不把这一层想清楚,后面全是玄学 bug。
1.1 labelme 的 JSON 结构:一个文件装一张图的所有标注
labelme 是典型的"一图一 JSON"。你标注完一张图,它会生成一个和图片同名的.json文件,里面记录了图片路径、尺寸、以及一个shapes数组。每个 shape 代表一个标注对象,核心字段有这么几个:
label:类别名称,是字符串,比如 "cat"、"person"、"defect"。points:构成这个形状的点坐标数组,单位是像素,原点在图片左上角。shape_type:形状类型,常见的有polygon(多边形)、rectangle(矩形)、circle、line、point。imageWidth/imageHeight:图片的宽高,这个字段非常关键,YOLO 归一化就靠它。
一个典型的矩形标注长这样:
{ "version": "5.1.1", "shapes": [ { "label": "person", "points": [[102.5, 88.0], [340.2, 415.7]], "shape_type": "rectangle", "flags": {} } ], "imagePath": "0001.jpg", "imageWidth": 1280, "imageHeight": 720 }注意points里只有两个点,分别是矩形的左上角和右下角。但 labelme 并不强制你按"左上、右下"的顺序点,有人习惯从右下往左上拉,那points就是反的。这一点在转换时必须处理,否则算出来的宽高会是负数。
1.2 YOLO 的 TXT 结构:一行一个框,全部归一化
YOLO 的标注是"一图一 TXT",文件名和图片同名,内容每一行代表一个目标,格式固定为五个值:
class_id x_center y_center width height这里有几个硬性约定,新手最容易搞错:
- 所有坐标都是归一化的,也就是除以图片宽高之后的值,范围在 0 到 1 之间。
x_center、y_center是框中心点,不是左上角。width、height是框的宽高,也是归一化后的。class_id是从 0 开始的整数索引,不是类别名字符串。
比如上面那个 person 框,假设它是第 0 类,转换后大概是这样:
0 0.17285 0.34979 0.18570 0.455141.3 两种格式的核心差异对照
把差异列成表,转换时对着看,能省很多事:
| 维度 | labelme JSON | YOLO TXT |
|---|---|---|
| 文件粒度 | 一图一 JSON | 一图一 TXT |
| 坐标单位 | 像素绝对值 | 归一化 0~1 |
| 框表示 | 左上+右下两点 | 中心点+宽高 |
| 类别表示 | 字符串 label | 整数 class_id |
| 形状支持 | 多边形/矩形/圆/线/点 | 仅矩形框 |
| 尺寸来源 | JSON 内自带 | 需外部提供或读图 |
这张表里最要命的是最后一行:YOLO 的 TXT 本身不存图片尺寸,所以从 YOLO 转回 labelme 时,你必须知道每张图的宽高,否则没法还原成像素坐标。这是双向转换里最容易翻车的地方,后面会专门讲。
2. labelme 转 YOLO:从 JSON 到归一化 TXT 的完整链路
labelme 转 YOLO 是更常见的需求,因为大家通常先用 labelme 标,再拿去训练 YOLO。这个方向的转换逻辑相对清晰,但细节坑不少,尤其是多边形和类别映射。
2.1 矩形框的坐标换算:中心点公式别记反
矩形框的转换是基础,公式必须记牢。假设 labelme 的两个点是(x1, y1)和(x2, y2),图片宽W、高H:
x_min = min(x1, x2) y_min = min(y1, y2) x_max = max(x1, x2) y_max = max(y1, y2) x_center = (x_min + x_max) / 2 / W y_center = (y_min + y_max) / 2 / H width = (x_max - x_min) / W height = (y_max - y_min) / H这里用min/max就是为了兼容"从右下往左上拉"的情况。我见过有人直接拿points[0]当左上角,结果一半的框位置全错,排查了半天才发现是标注顺序问题。所以别偷懒,老老实实取 min/max。
2.2 多边形怎么变成矩形框:取外接矩形
labelme 支持多边形标注,但 YOLO 检测只认矩形框。这时候通常的做法是取多边形的外接矩形(bounding box),也就是所有点里最小的 x、y 和最大的 x、y:
xs = [p[0] for p in points] ys = [p[1] for p in points] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys)需要提醒的是,外接矩形会引入背景冗余。如果你的目标本身是细长或倾斜的,外接框里会混进大量无关像素,可能影响检测精度。这种情况要么改用分割任务(YOLO 也支持 seg 格式,存多边形归一化点),要么在标注阶段就尽量用矩形框。我个人的经验是:检测任务就老老实实标矩形,别用多边形再转,转出来的框质量不稳定。
2.3 类别名到 class_id 的映射:顺序必须固定
YOLO 训练时,class_id和类别名的对应关系由data.yaml里的names列表决定。所以转换脚本里必须维护一份固定的映射表,比如:
class_map = { "person": 0, "car": 1, "dog": 2, }这里有个大坑:映射表的顺序必须和训练时names的顺序完全一致。我见过一个项目,转换脚本里 person 是 0,但data.yaml里 person 写在第 2 位,结果模型把人和狗认反了,训练 loss 看着正常,推理结果一塌糊涂。所以建议把映射表单独抽成一个配置文件,转换和训练共用同一份,从源头杜绝不一致。
2.4 批量转换脚本:遍历目录 + 跳过空标注
单张转换没意义,实际都是批量。下面是一个可直接用的脚本骨架:
import json import os from pathlib import Path class_map = {"person": 0, "car": 1, "dog": 2} def convert_one(json_path, out_dir): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) W = data["imageWidth"] H = data["imageHeight"] lines = [] for shape in data["shapes"]: label = shape["label"] if label not in class_map: continue pts = shape["points"] xs = [p[0] for p in pts] ys = [p[1] for p in pts] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) xc = (x_min + x_max) / 2 / W yc = (y_min + y_max) / 2 / H w = (x_max - x_min) / W h = (y_max - y_min) / H lines.append(f"{class_map[label]} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}") out_path = Path(out_dir) / (Path(json_path).stem + ".txt") with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) def batch_convert(json_dir, out_dir): os.makedirs(out_dir, exist_ok=True) for name in os.listdir(json_dir): if name.endswith(".json"): convert_one(os.path.join(json_dir, name), out_dir)几个实操要点:
- 空标注要生成空文件。如果一张图没有任何目标,YOLO 期望有一个空的 txt(或者干脆没有这个文件,取决于训练框架)。我建议生成空文件,避免训练时找不到标签报错。
- 坐标保留 6 位小数足够,再多没意义还占空间。
- 编码统一用 utf-8,否则中文路径或中文标签会炸。
2.5 转换后的自检:别等训练报错才发现
转完不要直接开训,先做几项检查:
- 随机抽几张图,用可视化脚本把 YOLO 框画回原图,肉眼确认位置对不对。
- 检查所有坐标是否都在 0~1 之间,出现负数或大于 1 的,说明尺寸或公式有问题。
- 统计每个类别的框数量,和 labelme 里的标注数量对一下,防止漏转。
我一般会写一个简单的可视化函数,把归一化坐标乘回宽高再画框,这一步能拦下 90% 的低级错误。
3. yolo2labelme:反向转换里那些容易忽略的细节
从 YOLO 转回 labelme 的需求相对少,但也不是没有。比如你想用 labelme 的可视化工具检查 YOLO 标注质量,或者想把已有的 YOLO 数据集导入 labelme 做二次修正。这个方向的难点在于:TXT 里没有图片尺寸,也没有类别名。
3.1 图片尺寸从哪来:必须读图或查表
YOLO 的 TXT 只有归一化坐标,要还原成像素,必须知道每张图的W和H。有两种做法:
- 直接读图:用 PIL 或 OpenCV 打开对应图片,取
img.size。简单可靠,但要求图片和 TXT 在同一目录或能对应上。 - 查尺寸表:如果图片不在手边,就得提前准备一份
文件名 -> (W, H)的映射表。
我推荐直接读图,因为最不容易出错。用 PIL 的话:
from PIL import Image img = Image.open(img_path) W, H = img.size注意 OpenCV 读出来是(H, W)顺序,PIL 是(W, H),别搞混,这个坑我踩过不止一次。
3.2 类别名从哪来:反向查映射表
YOLO 的class_id是整数,labelme 需要字符串 label。所以你得有一份id -> name的反向映射。如果训练时用的是data.yaml,直接读它的names列表就行:
id2name = {i: name for i, name in enumerate(names)}这里同样强调一致性:反向映射必须和当初正向转换用的是同一份类别定义,否则转出来的标签名对不上,白忙活。
3.3 中心点宽高还原成左上右下
反向公式是正向的逆运算:
x_min = (x_center - width / 2) * W y_min = (y_center - height / 2) * H x_max = (x_center + width / 2) * W y_max = (y_center + height / 2) * H还原出来的点是浮点数,labelme 能接受浮点坐标,但如果你想要整数,可以四舍五入。我一般保留一位小数,既精确又不至于太啰嗦。
3.4 生成符合 labelme 规范的 JSON
labelme 的 JSON 有一些字段是它读取时依赖的,不能省。一个最小可用的结构:
import json labelme_data = { "version": "5.1.1", "flags": {}, "shapes": [], "imagePath": img_name, "imageData": None, "imageWidth": W, "imageHeight": H, } for line in lines: cid, xc, yc, w, h = line.split() cid = int(cid) xc, yc, w, h = map(float, (xc, yc, w, h)) x_min = (xc - w / 2) * W y_min = (yc - h / 2) * H x_max = (xc + w / 2) * W y_max = (yc + h / 2) * H labelme_data["shapes"].append({ "label": id2name[cid], "points": [[x_min, y_min], [x_max, y_max]], "group_id": None, "shape_type": "rectangle", "flags": {}, }) with open(out_json, "w", encoding="utf-8") as f: json.dump(labelme_data, f, ensure_ascii=False, indent=2)几个关键点:
imageData设为None就行,labelme 会自己去读图片,不用把 base64 塞进去,否则 JSON 会巨大。shape_type必须是"rectangle",否则 labelme 打开时可能不认。points顺序是左上、右下,这个顺序 labelme 是认的,不用纠结。
3.5 反向转换的验证:用 labelme 打开看一眼
转完直接拿 labelme 打开生成的 JSON,看框的位置和标签对不对。这一步最直观,比写任何断言都管用。如果框整体偏移或缩放,八成是 W/H 搞反了,或者归一化坐标没乘对。
4. 双向转换里我踩过的坑和对应的解法
前面讲了流程,这一节专门讲坑。这些坑都是我在实际项目里真金白银踩出来的,写出来能帮你省下大量排查时间。
4.1 坐标越界:归一化后出现负数或大于 1
现象:转换后某些框的坐标是负数,或者大于 1,训练时直接报错或框跑到图外。
原因:通常是标注时框超出了图片边界。labelme 允许你把框拉到图片外面,但 YOLO 的归一化坐标理论上应该在 0~1 之间。
解法:转换时做一次裁剪(clip),把坐标限制在 0~1:
xc = min(max(xc, 0), 1) yc = min(max(yc, 0), 1) w = min(max(w, 0), 1) h = min(max(h, 0), 1)但更根本的做法是标注阶段就别让框出界,出界的框本身质量就有问题。
4.2 图片尺寸不一致:JSON 里的尺寸和实际图片对不上
现象:转换后框的位置整体偏移或缩放。
原因:labelme 的 JSON 里存了imageWidth和imageHeight,但如果图片后来被裁剪、缩放、替换过,JSON 里的尺寸就和实际图片不一致了。转换时如果信了 JSON 里的尺寸,算出来的归一化坐标就是错的。
解法:转换时以实际图片的尺寸为准,而不是 JSON 里的。用 PIL 读一下真实尺寸,覆盖 JSON 里的值。这个坑很隐蔽,因为 JSON 里的尺寸看起来"很合理",你不会怀疑它。
4.3 类别名大小写和空格:看不见的杀手
现象:某些类别的框全部丢失,或者映射到错误的 id。
原因:类别名里有看不见的空格,或者大小写不一致。比如"person"和"person "(末尾空格)在字典里是两个不同的 key。
解法:转换前对 label 做一次清洗:
label = shape["label"].strip()同时建议在标注规范里明确要求类别名统一小写、无空格。我见过一个团队,两个人分别标了"Car"和"car",结果训练时被当成两个类,模型直接懵了。
4.4 多边形点顺序混乱:外接矩形算错
现象:多边形转矩形后,框的位置或大小明显不对。
原因:多边形的点顺序不固定,有人顺时针点,有人逆时针点,甚至有人乱序点。如果你假设点是有序的,算外接矩形就会出错。
解法:永远用min/max遍历所有点,不要假设点的顺序。这是最稳妥的做法。
4.5 空标注和缺失文件:训练时的隐形炸弹
现象:训练时报"找不到标签文件",或者某些图被静默跳过。
原因:有些图没有目标,labelme 可能没生成 JSON,或者转换时没生成对应的空 TXT。
解法:转换脚本里显式处理空标注,生成空文件。同时训练前做一次完整性检查,确保每张图都有对应的标签文件(哪怕是空的)。
5. 把转换做成可复用的工具:工程化的一点思考
单次转换用脚本就够了,但如果你的项目要反复迭代,标注会不断更新,每次都手动跑脚本很容易出错。我的做法是把转换逻辑封装成一个命令行工具,支持配置文件和参数。
5.1 用配置文件管理类别映射
把类别映射、输入输出目录、是否裁剪等参数写进一个 YAML:
class_map: person: 0 car: 1 dog: 2 input_dir: ./labels_json output_dir: ./labels_txt clip: true脚本读这个配置,转换和训练共用同一份类别定义,彻底消除不一致。
5.2 加日志和统计,方便排查
转换时打印统计信息,比如处理了多少文件、跳过了多少、每个类别多少个框。这些信息在排查问题时非常有用。我一般会在转换结束后输出一个汇总:
处理文件数: 1200 跳过文件数: 3 person: 3400 个框 car: 2100 个框 dog: 800 个框5.3 支持增量转换
如果只新增了几十张标注,没必要全量重转。可以记录已转换的文件列表,只处理新增或修改过的。这个优化在数据集大的时候能省不少时间。
6. 转换之外:一些关于标注流程的经验
格式转换只是标注流程里的一环,真正影响效率的是整个流程的设计。分享几点我的经验。
6.1 标注规范要提前定死
类别名、框的松紧程度、是否允许出界、多边形还是矩形,这些都要在标注开始前定好,写成文档发给所有标注人员。我见过太多项目,标到一半发现规范不统一,返工的成本远高于一开始多花半小时定规范。
6.2 转换脚本要版本化
转换脚本本身也是代码,要纳入版本管理。因为类别映射、坐标处理逻辑可能会变,出问题时能回溯到具体版本。别把脚本随手丢在桌面,过两个月自己都找不到。
6.3 转换后一定要可视化抽检
不管脚本写得多自信,转完都要抽几张图可视化确认。这一步花不了几分钟,但能拦住绝大多数低级错误。我习惯抽 10 张,覆盖不同类别和不同尺寸的图。
6.4 保留原始标注
转换是单向的,但原始标注要保留。万一转换逻辑有问题,还能从原始 JSON 重新转。别转完就把 JSON 删了,这个教训我吃过一次,重标了一整天。
7. 常见问题速查
把高频问题整理成表,遇到时直接对号入座:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 框位置整体偏移 | 图片尺寸不一致 | 用实际图片尺寸覆盖 JSON 尺寸 |
| 框大小不对 | 归一化公式用错 | 检查是否除以了 W 和 H |
| 部分类别丢失 | 类别名不匹配 | 检查大小写、空格、映射表 |
| 坐标为负或大于 1 | 框超出图片边界 | 加 clip 或修正标注 |
| 多边形转框不准 | 点顺序假设错误 | 用 min/max 遍历所有点 |
| 训练找不到标签 | 空标注未生成文件 | 显式生成空 TXT |
| 反向转换框错位 | W/H 顺序搞反 | PIL 是 (W,H),OpenCV 是 (H,W) |
这张表基本覆盖了 90% 的转换问题,遇到时先对照排查,能省下大量瞎试的时间。
8. 写在最后的一点个人体会
labelme 和 YOLO 的格式互转,技术上并不复杂,无非是坐标换算和类别映射。但真正让人头疼的从来不是公式,而是数据本身的不规范:尺寸对不上、类别名不统一、框出界、点顺序乱。这些问题在单个文件上看不出来,一旦批量处理就集中爆发。
我的建议是,把转换脚本当成项目基础设施来对待,而不是临时用的一次性代码。类别映射抽成配置、加日志统计、转换后可视化抽检、保留原始标注,这几件事做下来,转换环节基本就不会再出问题了。另外,标注规范一定要在动手标之前定死,这比任何脚本优化都管用。格式转换只是手段,干净、一致的数据才是目标检测项目真正的地基。