做目标检测的朋友大概率都经历过这个场景在标注工具里辛辛苦苦框了几百上千张图导出的时候发现是 JSON 格式兴冲冲准备丢进 YOLOv8 训练结果发现人家要的是每张图配一个 txt里面还得是归一化后的类别加坐标。这时候要么手动改要么找脚本要么干脆重新标一遍——最后一种当然没人愿意干。我前后在几个检测项目里反复折腾过这套转换流程从最早的照抄网上脚本到后来自己重写、加校验、处理各种脏数据踩过的坑足够写一篇完整的复盘。这篇就把 labelme 的 JSON 到 YOLOv8 的 txt 这条链路彻底讲透包括格式差异的底层逻辑、转换脚本的完整实现、坐标归一化的计算细节、多类别与中文标签的处理、以及那些只有真跑过数据才会遇到的边界情况。不管你是刚接触目标检测的新手还是已经训练过几轮但被数据格式卡住的老手都能从里面找到能直接抄作业的部分。1. 先搞清楚两种格式到底差在哪很多人转换失败根子不在脚本写错而在于没弄明白 labelme 的 JSON 和 YOLOv8 的 txt 在数据组织哲学上就是两套东西。labelme 是一张图一个文件所有信息塞进去YOLOv8 是一张图一个 txt一行一个目标坐标全部归一化。不理解这个差异后面调 bug 就是盲人摸象。1.1 labelme JSON 的结构长什么样labelme 保存的 JSON 本质上是一个描述文件核心字段就那么几个。imagePath记录原图文件名imageWidth和imageHeight记录图像尺寸shapes是一个数组里面每个元素代表一个标注对象包含label类别名、points多边形或矩形的顶点坐标、shape_type矩形是 rectangle多边形是 polygon。矩形标注的points通常只有两个点左上角和右下角坐标是相对原图的绝对像素值。这里有个容易被忽略的点points的坐标顺序并不总是左上到右下。labelme 允许用户从任意方向拖拽画框所以你可能拿到的是左下加右上甚至右下加左上。如果脚本里直接拿points[0]当左上角、points[1]当右下角去算宽高遇到反向拖拽的框就会算出负数宽高转出来的 txt 坐标直接越界。这是我在第一个项目里栽的第一个跟头当时训练 loss 一直不降排查了半天才发现是标注框坐标有负值。另外imageData字段有时候会把整张图 base64 编码塞进 JSON导致单个文件几十兆。转换脚本完全不需要这个字段但如果你用json.load全量读进来再处理内存会被白白吃掉。批量处理几千张图的时候这个细节能决定脚本是跑十分钟还是跑一小时。1.2 YOLOv8 txt 的每一行代表什么YOLOv8 的标注文件是纯文本每行一个目标格式固定为五个值class_id center_x center_y width height。全部用空格分隔全部是归一化到 0 到 1 之间的浮点数。class_id是从 0 开始的整数类别索引center_x和center_y是框中心点相对图像宽高的比例width和height是框宽高相对图像宽高的比例。归一化这一步是新手最容易出错的地方。绝对像素坐标除以图像尺寸得到比例这个逻辑本身简单但要注意除的是宽度还是高度不能搞混——x 相关除以宽y 相关除以高。我见过有人图省事全除以宽度遇到非正方形图片直接全歪。还有一个隐含要求YOLOv8 期望的 txt 文件名必须和图片文件名主干一致比如IMG_001.jpg对应IMG_001.txt放在与图片平行的labels目录下。目录结构不对训练时框架找不到标签报的错还特别隐晦往往只提示没有找到标签不会告诉你路径拼错了。对比维度labelme JSONYOLOv8 txt文件粒度一图一 JSON一图一 txt坐标形式绝对像素值归一化 0-1 浮点目标表示左上右下两点矩形中心点加宽高类别表示字符串标签名从 0 开始的整数索引多目标组织shapes 数组多行文本附加信息含图像尺寸、base64 等仅坐标与类别这张表建议存下来每次转换出问题先对照一遍八成能定位到是哪个维度没对齐。2. 转换脚本的核心逻辑拆解网上能搜到大量一行命令搞定的转换脚本但直接拿来用往往跑不通因为每个人的数据都有各自的脏法。与其抄一个黑盒脚本不如把核心逻辑拆开理解这样遇到问题能自己改。整个转换可以拆成四步读 JSON、提取有效标注、坐标变换、写 txt。每一步都有讲究。2.1 读 JSON 时该跳过哪些字段前面提到imageData会拖慢速度处理办法是在json.load之后立刻把不需要的字段丢掉或者更彻底一点用流式读取只取需要的键。实际写脚本时我习惯先定义一个白名单imagePath、imageWidth、imageHeight、shapes其余一律不碰。这样即使 JSON 里塞了再多的冗余信息内存占用也是可控的。还有一个隐蔽的坑imageWidth和imageHeight这两个字段并不是所有 labelme 版本都会写。有些老版本或者经过二次编辑的文件里可能缺失这时候就得靠PIL打开原图去读真实尺寸。所以稳妥的做法是优先用 JSON 里的尺寸缺失时回退到读图。读图这一步还能顺带校验图片是否损坏一举两得。import json import os from PIL import Image def load_annotation(json_path, image_dir): with open(json_path, r, encodingutf-8) as f: data json.load(f) w data.get(imageWidth) h data.get(imageHeight) if not w or not h: img_path os.path.join(image_dir, data[imagePath]) with Image.open(img_path) as im: w, h im.size return data, w, h这段代码看着简单但encodingutf-8不能省。labelme 的标签名经常带中文Windows 下默认编码读进来就是乱码后面类别映射全错。2.2 从 shapes 里筛出真正能用的框shapes数组里不一定全是矩形。有人用多边形标注有人用点标注甚至有人误操作留下空 shapes。YOLOv8 检测任务只认矩形框所以转换前必须过滤shape_type为rectangle的才处理polygon要么跳过要么取外接矩形点标注直接丢弃。取多边形外接矩形是个实用技巧。有些标注者习惯用多边形精细勾勒目标轮廓但检测任务其实只需要外接框。做法是遍历points里所有点分别取 x 和 y 的最小最大值组成左上右下两点。这样既保留了标注意图又符合 YOLO 格式要求。不过要提醒一句如果多边形是凹形或者有离群点外接矩形可能偏大这时候最好人工抽查几张确认框的质量可接受。def extract_boxes(shapes): boxes [] for s in shapes: st s.get(shape_type, rectangle) pts s.get(points, []) if len(pts) 2: continue if st rectangle: (x1, y1), (x2, y2) pts[0], pts[1] elif st polygon: xs [p[0] for p in pts] ys [p[1] for p in pts] x1, x2 min(xs), max(xs) y1, y2 min(ys), max(ys) else: continue boxes.append((s[label], x1, y1, x2, y2)) return boxes注意这里我故意没在提取阶段就归一化而是先把绝对坐标的框收集起来统一在下一步做变换。这样做的好处是坐标修正比如处理反向拖拽可以集中在一处逻辑更清晰。2.3 坐标变换从左上右下到中心宽高这是整个转换的数学核心也是最容易写错的地方。给定左上角(x1, y1)和右下角(x2, y2)以及图像宽高W、H变换公式是中心 x (x1 x2) / 2 / W中心 y (y1 y2) / 2 / H宽 abs(x2 - x1) / W高 abs(y2 - y1) / Habs那一步就是对付反向拖拽的。不管用户从哪个角开始画取绝对值后宽高都是正的。但光取绝对值还不够还得保证x1 x2、y1 y2否则中心点算出来虽然对但后续如果要做裁剪或者可视化会出问题。所以更稳的写法是先规范化x1, x2 min(x1, x2), max(x1, x2)y 同理然后再套公式。还有一个必须做的校验归一化后的值必须落在 0 到 1 之间。理论上只要标注没超出图像边界就不会越界但实际数据里框超出边界的情况太常见了——标注者手一抖拖到画布外面或者图像被裁剪过但 JSON 没更新。处理策略有两种一是直接裁剪到[0, 1]二是丢弃越界框。我倾向于裁剪因为轻微越界通常不影响训练直接丢会损失样本。但如果越界比例超过某个阈值比如框有一半在图像外那就该丢弃这种框本身质量就有问题。def to_yolo(box, W, H, clipTrue): label, x1, y1, x2, y2 box 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 if clip: cx min(max(cx, 0), 1) cy min(max(cy, 0), 1) bw min(max(bw, 0), 1) bh min(max(bh, 0), 1) return cx, cy, bw, bh这段代码我用了很多次稳定可靠。唯一要注意的是clip之后宽高可能变成 0框完全在图像外这种行写进 txt 是无效的得在写文件前过滤掉bw 0 or bh 0的样本。2.4 类别名到整数索引的映射YOLOv8 的 txt 里第一列是整数而 labelme 里是字符串标签。所以必须维护一个映射表把标签名转成从 0 开始的索引。这个映射表通常写成classes.txt或者直接硬编码在脚本里顺序必须和训练时data.yaml里的names完全一致否则类别全错位。我的习惯是先扫描一遍所有 JSON收集出现过的所有标签名排序后生成classes.txt同时把这个顺序固化下来。这样即使后续新增了标注只要不改变已有标签的顺序旧数据就不用重新转换。如果中途确实要加新类别就追加到列表末尾绝不能插在中间——插中间会导致所有已有索引偏移之前的训练数据全废。def build_class_map(json_paths): labels set() for p in json_paths: with open(p, r, encodingutf-8) as f: data json.load(f) for s in data.get(shapes, []): labels.add(s[label]) return {name: idx for idx, name in enumerate(sorted(labels))}sorted是为了让结果可复现不然每次跑脚本顺序可能不一样映射表就漂移了。中文标签排序也没问题Python 的字符串排序对 Unicode 是稳定的。3. 批量处理时的目录组织与工程化单张转换跑通只是开始真实项目动辄几千张图脚本必须能批量跑、能断点续跑、能报告异常。这一块是很多教程忽略的但恰恰是决定你加班到几点的地方。3.1 输入输出的目录结构怎么设计labelme 导出的数据通常是一个文件夹里混着图片和 JSON或者图片和 JSON 分两个文件夹。YOLOv8 要求的目录结构是images/train、images/val、labels/train、labels/val平行排列。转换脚本要做的就是把散落的 JSON 映射到对应的labels目录文件名主干保持一致。我一般会先建一个转换工作区结构如下dataset/ images/ train/ val/ labels/ train/ val/ classes.txt data.yaml转换时按图片所属的划分train 还是 val决定 txt 写到哪个子目录。划分逻辑可以按比例随机分也可以按预先准备好的列表分。随机分要固定随机种子不然每次跑结果不同训练和验证集就串了。3.2 断点续跑与异常隔离批量转换最怕的是跑到第 800 张时因为一个坏 JSON 崩掉前面的成果虽然还在但你不知道哪些转了哪些没转。解决办法是每处理完一张就记录日志重跑时跳过已完成的。更简单粗暴的做法是转换前先检查目标 txt 是否已存在存在就跳过。这样即使中途崩了重跑会自动续上。异常隔离同样重要。单个 JSON 解析失败不应该中断整个批次用try/except包住单张处理逻辑把失败的文件路径记到一个error.log里最后统一人工检查。我遇到过最离谱的情况是一个 JSON 文件被文本编辑器打开后误保存末尾多了几个乱码字符json.load直接抛异常。如果没做隔离整批数据就卡在那一个文件上。def batch_convert(json_dir, image_dir, out_dir, class_map, log_path): errors [] for name in os.listdir(json_dir): if not name.endswith(.json): continue stem os.path.splitext(name)[0] out_txt os.path.join(out_dir, stem .txt) if os.path.exists(out_txt): continue try: data, W, H load_annotation(os.path.join(json_dir, name), image_dir) boxes extract_boxes(data.get(shapes, [])) lines [] for b in boxes: if b[0] not in class_map: continue cx, cy, bw, bh to_yolo(b, W, H) if bw 0 or bh 0: continue lines.append(f{class_map[b[0]]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) with open(out_txt, w, encodingutf-8) as f: f.write(\n.join(lines)) except Exception as e: errors.append(f{name}: {e}) if errors: with open(log_path, w, encodingutf-8) as f: f.write(\n.join(errors))浮点数保留六位小数是经验值。YOLO 官方示例里常见六位精度足够文件也不会太大。保留太多位没必要太少比如两位在超大图上会有明显误差。3.3 转换后的自检清单脚本跑完不代表万事大吉必须做一轮自检。我通常检查这几项txt 文件数量是否和图片数量匹配允许少量无标注图没有 txt、随机抽几张把框画回原图看位置对不对、统计每个类别的框数量看有没有异常为零的类别、检查有没有坐标超出[0,1]的行。把框画回原图这一步特别值得做。写个简单的可视化脚本读 txt 反算回像素坐标用PIL的ImageDraw画矩形肉眼一看就知道转换对不对。我靠这一步发现过一次 y 轴没归一化的问题——txt 里 y 坐标全是几百的整数明显是忘了除以高度。这种错误光看 txt 数字是看不出来的画出来一眼就露馅。4. 那些只有真跑过数据才会遇到的坑前面讲的是标准流程但真实数据永远比标准复杂。这一节列几个我实际踩过、且网上教程很少提的情况遇到时能帮你省下大半天。4.1 中文标签与特殊字符labelme 里用中文当标签名很常见比如行人车辆。转换时如果脚本没指定 UTF-8读进来就是乱码映射表里出现一堆问号最后 txt 里的类别索引全乱。解决办法前面提过读写都显式指定encodingutf-8。另外标签名里带空格、斜杠、点号的情况也要注意这些字符在生成文件名或写 yaml 时可能出问题建议在classes.txt里保持原样但在映射时做一次清洗把特殊字符替换掉。还有一个隐蔽问题同一个目标在不同 JSON 里标签名大小写不一致比如Car和car。如果不做统一会被当成两个类别训练时类别数翻倍。转换前最好先统计所有标签名人工确认一遍有没有这种变体统一成一种写法。4.2 空标注图与全背景样本有些图片里确实没有目标labelme 导出的 JSON 里shapes是空数组。这种图要不要保留答案是保留但要生成一个空的 txt 文件。YOLOv8 训练时空 txt 代表这张图是纯背景对抑制误检有帮助。如果直接跳过不生成 txt框架会认为这张图没有标注文件可能报错或者忽略效果不如显式给个空文件。但要注意空 txt 和转换失败没生成 txt在文件层面看起来一样所以自检时不能只看 txt 是否存在还要结合原始 JSON 的shapes是否为空来判断。我在日志里会单独记录空标注图的数量方便核对。4.3 图像尺寸与 JSON 记录不一致这个坑比较阴。有时候图片被后期处理过比如统一缩放、裁剪但 JSON 里的imageWidth、imageHeight还是旧的。这时候如果信了 JSON 的尺寸去做归一化框的位置会整体偏移。检测办法是转换时用PIL读一次真实尺寸和 JSON 里的对比不一致就报警并采用真实尺寸。with Image.open(img_path) as im: real_w, real_h im.size if (real_w, real_h) ! (w, h): print(f尺寸不一致: {json_path}, JSON{w}x{h}, 实际{real_w}x{real_h}) w, h real_w, real_h多这一步开销很小但能避免一整批数据白转。我现在的脚本默认都走真实尺寸JSON 里的尺寸只作为参考。4.4 文件名主干冲突如果不同子目录下有同名图片比如train/001.jpg和val/001.jpg转换后 txt 都叫001.txt写到同一个 labels 目录就会互相覆盖。解决办法是保持 images 和 labels 的目录结构镜像train 的 txt 进labels/trainval 的进labels/val不要全平铺到一个目录。这个细节在数据量小的时候不容易暴露等数据一多、划分一复杂就出问题。5. 从转换到训练data.yaml 怎么配才不返工txt 转完最后一步是把data.yaml配对这一步配错前面全白干。YOLOv8 的 yaml 核心就几项训练和验证图片目录、类别数、类别名列表。类别名的顺序必须和转换时用的class_map完全一致这是硬约束。path: ./dataset train: images/train val: images/val nc: 3 names: 0: person 1: car 2: bicyclenames写成字典形式比列表形式更不容易错位因为键就是索引一眼能看出对应关系。如果用列表顺序错了很难发现。另外nc必须等于names的长度多一个少一个都会在训练启动时报错。配好之后正式训练前先跑一个极小的验证拿几张图过一遍数据加载器确认能读到标签、类别索引在合理范围。YOLOv8 启动时会打印数据集统计包括每个类别的实例数扫一眼如果某个类别是 0 或者数量明显不对说明转换或映射有问题这时候停下来修比训练到一半发现强得多。我个人在实际操作中的体会是这套转换流程真正花时间的从来不是写脚本而是处理各种不规范的数据。所以与其追求脚本多优雅不如把校验和日志做扎实让每一张转换失败的图都有迹可循。另外强烈建议第一次转换后一定做可视化抽查画框那几十行代码的投入能帮你挡掉后面训练阶段几小时的无效排查。数据格式这关过了后面调模型、调超参才有意义。
阅读完成 · 觉得有帮助?