简介:本资源是一套面向计算机视觉初学者与YOLO模型实践者的自动化工具集,专为解决labelme标注数据向YOLOv8语义分割格式转换及数据集划分的痛点而设计。资源包含2个核心Python脚本(convert_folder.py用于批量JSON→YOLO文本格式转换,train_example.py实现按比例自动划分训练/验证集)、5个labelme生成的JSON标注文件、4张JPEG与2张JPG图像样本,以及README.md使用说明,共14个文件,总大小仅1.95MB,轻量易部署。目前已有1294人学习下载,适用于图像分割项目快速启动、课程实验数据准备或竞赛备赛中的数据预处理环节。用户可直接运行脚本完成多边形标注→归一化坐标+类别编码→YOLOv8兼容txt标签→按类均衡划分的全流程,无需手动解析JSON结构或编写划分逻辑,显著降低语义分割任务的数据准备门槛。
1. 为什么要从labelme转到YOLOv8分割格式
做视觉项目时,标注工具和数据格式的匹配问题,往往比模型训练本身更让人头疼。我最初接触labelme,是因为它上手简单,安装完打开就能画多边形,标注结果直接存成JSON,还能在页面上回看,确实适合小团队或者个人项目快速起步。但真正进入YOLOv8训练环节后,问题就来了:YOLOv8分割要求的数据组织方式和labelme的JSON存储方式完全不是一回事,如果你不想每次训练前手动整理几百个文件,写一个转换脚本几乎是逃不掉的。
先说清楚两组格式的根本差异。labelme输出的核心是一个JSON文件,里面记录了图片路径、尺寸、以及所有标注对象的信息。每个对象包含label字段(类别名)、points字段(多边形顶点列表,单位是像素)、shape_type字段(多边形还是矩形、圆、线等)。YOLOv8分割格式则是非常朴素的TXT文件:每行对应图片里的一个目标,写成“类别编号 归一化x1 归一化y1 归一化x2 归一化y2 ...”,全部坐标必须缩放到0到1之间。这意味着,从labelme到YOLOv8,本质上要做三件事:把类别名翻译成类别编号、把像素坐标变成归一化坐标、把JSON拆分成与图片一一对应的TXT文件。
为什么不能直接在labelme里导出YOLO格式?labelme本身其实也支持部分导出功能,但实际使用中我发现它的导出对中文路径、类别映射、批量处理的支持都比较薄弱,而且无法同时完成训练集和验证集的自动划分。多数项目场景里,标注工人只管画框画多边形,数据工程师要负责把标注变成能喂给模型的格式,中间的转换步骤必须可控、可复现。自己写一个脚本,虽然需要花一点时间,但类别映射、数据划分、格式校验都在自己的手里,后续要改也方便。
这篇文章适合的人群很明确:正在使用labelme做多边形标注,但转头要用Ultralytics YOLOv8训练语义分割或者实例分割模型的同学;以及那些已经有一批JSON标注文件、想批量转换成YOLO格式并顺便划分好训练集和验证集的开发者。看完之后,你不仅能拿到一个能直接跑的转换脚本,还能理解每一步操作背后的原因,遇到问题也知道该从哪里排查。
2. 转换方案的整体设计与关键决策
在动手写脚本之前,我先把整个方案想清楚了。这个环节特别重要,因为转换脚本虽然逻辑不复杂,但如果没有提前设计好目录结构和类别映射规则,后面很容易陷入改来改去的泥潭。
2.1 输出的数据集目录结构
YOLOv8训练时,通常通过一个data.yaml文件告诉模型图片和标签在哪里。常见的做法是将数据集组织成如下结构:
dataset/ ├── data.yaml ├── images/ │ ├── train/ │ └── val/ └── labels/ ├── train/ └── val/images和labels下的文件名必须一一对应,比如images/train/001.jpg对应labels/train/001.txt。YOLO训练时会根据图片路径自动去labels目录下找同名TXT,所以目录组织必须严格按这个规则来。我建议转换脚本直接生成这种结构,并且默认把原始图片复制到images目录下,这样整份数据集是独立的,分享给同事或者换机器训练时不会因为原图路径变动而失效。
2.2 类别映射的三种方案
这是转换中最容易翻车的地方。labelme里的类别是字符串,比如“car”“person”,YOLO格式要求整数编号,而且编号要从0开始连续排列。我总结下来有三种处理方式:
第一种是写死一个类别列表。如果你已经知道自己数据集中有哪几类,直接在脚本里用一个列表写死,简单粗暴,最可控。缺点是标注过程中如果新增了类别,脚本就报错或者漏掉。
第二种是自动收集类别。遍历所有JSON文件,把出现过的label集合起来,按字母排序或者按首次出现顺序分配编号。这种方式省事,但有个隐患:如果你这次收集到的类别顺序和上次不一样,同一个类别的编号就会变,模型权重就和标注对不上了。
第三种是使用一个固定的classes.txt文件。脚本运行前先检查有没有这个文件,有就按里面的顺序分配编号;没有就自动收集并生成一个新的classes.txt。这样既保留自动化的便利,又能锁定类别顺序。我个人倾向第三种,安全性最高,重新跑转换时也不会出意外。
2.3 自动划分训练集和验证集的逻辑
标题里说的“自动划分训练集和”,我实现时考虑的是按图片文件维度进行随机划分,而不是按标注对象划分。原因是同一张图片上的所有目标在训练时是一个整体,不能拆开。举个例子,一张街景图里同时有车、行人、交通标志,如果只把车放进训练集、把行人放进验证集,模型训练和评估的语义就完全乱了。
划分比例我默认设置成8:2。这个比例在多数场景下都合理,如果你的标注数据量特别大,可以适当调整;但如果数据量本身只有几百张,我更建议多分一些到验证集,甚至采用K折交叉验证的思路来评估模型。脚本里会固定一个随机种子,这样每次运行生成的划分结果完全一致,这对调试和复现非常有帮助。
2.4 关于语义分割和实例分割的边界
这里先澄清一个概念。YOLOv8官方的分割模型(yolov8n-seg、yolov8s-seg等)实际上做的是实例分割,也就是每个目标单独输出一个掩码。从labelme的JSON里读取的多边形,本身就是按实例逐个标注的,所以转换为TXT后,每个实例占一行,正好对应YOLOv8的训练要求。
如果你追求的是传统意义上的逐像素语义分割,即每个像素都只属于一个类别,那么YOLOv8-seg也不是不能用,只是它天然允许同一类别的多个实例分开表达,训练后的推理结果也会保留实例信息。从数据转换角度来说,labelme多边形转YOLO分割格式的流程是一样的,不影响你后续选择训练范式。
3. 核心实现:一个可复用的转换脚本
直接上代码。我写这个脚本的原则是:命令行参数要清晰,读别人看不懂没关系,关键是半年之后你自己还能看懂。脚本支持三件事:批量转换labelme JSON为YOLO格式TXT、自动收集或指定类别映射、按比例划分训练集和验证集。
3.1 完整代码与使用方式
import argparse import json import os import random import shutil from glob import glob from pathlib import Path def collect_classes(json_dir): class_set = set() for json_path in glob(os.path.join(json_dir, "*.json")): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) for shape in data["shapes"]: class_set.add(shape["label"]) return sorted(class_set) def normalize_points(points, width, height): norm_points = [] for x, y in points: nx = min(max(x / width, 0.0), 1.0) ny = min(max(y / height, 0.0), 1.0) norm_points.append(f"{nx:.6f} {ny:.6f}") return " ".join(norm_points) def convert_single_json(json_path, class_map, output_label_dir): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) width = data["imageWidth"] height = data["imageHeight"] if width == 0 or height == 0: print(f"[Skip] {json_path}: invalid image size") return None base_name = Path(json_path).stem label_lines = [] for shape in data["shapes"]: label = shape["label"] if label not in class_map: print(f"[Warn] {json_path}: unknown label '{label}' skipped") continue points = shape["points"] if shape["shape_type"] == "rectangle": # labelme矩形只有两个对角点,转成四个点再归一化 x1, y1 = points[0] x2, y2 = points[1] points = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] if len(points) < 3: print(f"[Warn] {json_path}: polygon has < 3 points, skipped") continue class_id = class_map[label] coords = normalize_points(points, width, height) label_lines.append(f"{class_id} {coords}") if not label_lines: print(f"[Warn] {json_path}: no valid labels after conversion") return None out_txt = os.path.join(output_label_dir, base_name + ".txt") with open(out_txt, "w", encoding="utf-8") as f: f.write("\n".join(label_lines) + "\n") return out_txt def main(): parser = argparse.ArgumentParser(description="labelme JSON to YOLOv8 segmentation dataset") parser.add_argument("--json_dir", required=True, help="labelme JSON files directory") parser.add_argument("--image_dir", required=True, help="original images directory") parser.add_argument("--output_dir", default="yolo_dataset", help="output dataset directory") parser.add_argument("--val_ratio", type=float, default=0.2, help="validation set ratio") parser.add_argument("--seed", type=int, default=42, help="random seed") parser.add_argument("--classes_file", default="", help="optional fixed classes.txt file") args = parser.parse_args() # 1. 收集类别映射 if args.classes_file and os.path.exists(args.classes_file): with open(args.classes_file, "r", encoding="utf-8") as f: class_names = [line.strip() for line in f if line.strip()] print(f"[Info] Loaded {len(class_names)} classes from {args.classes_file}") else: class_names = collect_classes(args.json_dir) if args.classes_file: Path(args.classes_file).write_text("\n".join(class_names), encoding="utf-8") print(f"[Info] Saved class list to {args.classes_file}") class_map = {name: i for i, name in enumerate(class_names)} print("[Info] Class mapping:", class_map) # 2. 准备输出目录 img_train_dir = os.path.join(args.output_dir, "images", "train") img_val_dir = os.path.join(args.output_dir, "images", "val") label_train_dir = os.path.join(args.output_dir, "labels", "train") label_val_dir = os.path.join(args.output_dir, "labels", "val") for d in [img_train_dir, img_val_dir, label_train_dir, label_val_dir]: os.makedirs(d, exist_ok=True) # 3. 转换全部JSON并收集有效文件 json_paths = glob(os.path.join(args.json_dir, "*.json")) valid_files = [] for json_path in json_paths: label_txt = convert_single_json(json_path, class_map, label_train_dir) if label_txt is None: continue base_name = Path(json_path).stem img_path = Path(args.image_dir) / f"{base_name}.jpg" # 有些图片可能是png或jpeg,做几个常见后缀检查 if not img_path.exists(): for ext in [".png", ".jpeg", ".bmp"]: candidate = Path(args.image_dir) / f"{base_name}{ext}" if candidate.exists(): img_path = candidate break if img_path.exists(): valid_files.append((json_path, img_path, base_name)) else: print(f"[Warn] {json_path}: image not found, label file removed") os.remove(label_txt) # 4. 随机划分训练集和验证集 random.seed(args.seed) random.shuffle(valid_files) val_count = int(len(valid_files) * args.val_ratio) val_files = valid_files[:val_count] train_files = valid_files[val_count:] for json_path, img_path, base_name in val_files: shutil.copy2(img_path, os.path.join(img_val_dir, img_path.name)) shutil.move( os.path.join(label_train_dir, base_name + ".txt"), os.path.join(label_val_dir, base_name + ".txt"), ) for json_path, img_path, base_name in train_files: shutil.copy2(img_path, os.path.join(img_train_dir, img_path.name)) # 5. 生成data.yaml classes_yaml = "\n".join(f" {i}: {name}" for i, name in enumerate(class_names)) yaml_content = f"""path: {os.path.abspath(args.output_dir)} train: images/train val: images/val names: {classes_yaml} """ with open(os.path.join(args.output_dir, "data.yaml"), "w", encoding="utf-8") as f: f.write(yaml_content) print(f"[Done] Total valid samples: {len(valid_files)}") print(f"[Done] Train: {len(train_files)}, Val: {len(val_files)}") print(f"[Done] Output dataset saved to {args.output_dir}") if __name__ == "__main__": main()使用方式很简单:
python labelme2yolo_seg.py --json_dir ./labelme_jsons --image_dir ./images --output_dir ./seg_dataset --val_ratio 0.2如果你希望类别顺序固定,预先准备好classes.txt再传参:
python labelme2yolo_seg.py --json_dir ./labelme_jsons --image_dir ./images --classes_file ./classes.txt3.2 代码里几个关键细节的说明
矩形处理部分是很容易被忽略的坑。labelme里画矩形时,points只存两个对角点,但YOLO分割需要至少三个点的闭合多边形,所以脚本里把矩形补齐成四个顶点。如果你用labelme标注的时候矩形用得多,这个细节能帮你避免大量“多边形点数不足”的报错。
坐标归一化时,我做了截断处理。理论上labelme画的多边形不会超出图像边界,但手动标注时鼠标一抖就可能画出边界,归一化后出现大于1的值,训练时模型可能会报错或者效果变差。截断到0到1区间是保险做法。
类别映射检查这里,如果JSON里出现了一个classes.txt中不存在的类别,我不会直接让脚本崩溃,而是打印警告并跳过这个对象。这种做法在处理脏数据时更安全,但最后需要人工确认跳过的对象数量是否在可接受范围内,否则标注就无声无息地丢了。
图片后缀的查找逻辑也简单但很实用。很多标注工具导出的图片路径和实际文件名对不上,我通过尝试多个常见后缀来解决,总比脚本中途因为找不到图而中断强。
3.3 data.yaml的生成与路径写法
data.yaml是YOLOv8训练时最重要的配置文件,其中path字段我使用了绝对路径。这里要注意,Ultralytics在解析YAML时,如果path是相对的,会基于当前工作目录来拼接,不同环境下行为不一致。我统一改成绝对路径,减少环境造成的困扰。train和val字段则保持相对路径,这样只要整个数据集目录移动位置,改一个path字段就能复用。
4. 转换后的自检与可视化
转换结束不代表万事大吉。我强烈建议在训练前先做一遍数据检查,否则训练跑起来才发现标签错位,浪费的时间就多了。
4.1 用脚本画图检查标注是否对齐
最简单的方式是用OpenCV把转换后的坐标重新画回原图,目测多边形的形状和位置。我写过一个小函数,核心逻辑就是读TXT、反归一化坐标、在原图上画线并显示类别名。这里的关键是可视化时必须用归一化坐标乘以原图尺寸,画出来的轮廓才会和原始标注重叠。
import cv2 import numpy as np def draw_yolo_seg(image_path, label_path, class_names): img = cv2.imread(image_path) h, w = img.shape[:2] with open(label_path, "r") as f: for line in f: parts = line.strip().split() cls_id = int(parts[0]) pts = np.array([[float(parts[i]) * w, float(parts[i + 1]) * h] for i in range(1, len(parts), 2)], dtype=np.int32) cv2.polylines(img, [pts], isClosed=True, color=(0, 255, 0), thickness=2) cv2.putText(img, class_names[cls_id], (pts[0][0], pts[0][1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) return img逐张看图虽然原始,但在小规模数据集上效率很高。我习惯随机抽10到20张图,把原图和标注叠加后拼接成一张对比图,一眼就能看出有没有坐标错位、类别串号的问题。
4.2 统计各类别数量分布
语义分割和数据划分中,类别不平衡是经常遇到的问题。训练前最好生成一份类别统计表,看看每个类别在训练集和验证集中的样本数是否接近。如果某个类别在训练集有2000个样本,验证集只有5个,那验证结果就不可靠。
我在实际项目中会把统计结果直接输出成一个CSV,按类别记录训练集和验证集的数量。表格形式方便直观比较。比如下面的结构:
| 类别 | 训练集数量 | 验证集数量 |
|---|---|---|
| person | 182 | 45 |
| car | 96 | 23 |
| traffic_light | 21 | 6 |
如果发现某个类别在验证集中数量过少,一个补救方案是用分层采样替代纯随机划分,也就是按类别比例来分配训练和验证样本。这也是为什么我建议在收集有效文件时保留每个文件包含的类别信息,后续做分层划分会方便很多。
4.3 用模型试跑一个epoch验证数据可用性
这是最有效的验证手段。在确认目录结构和标签格式没问题后,直接启动一个极简训练:
yolo train model=yolov8n-seg.pt data=seg_dataset/data.yaml epochs=1 imgsz=640一个epoch就能检查标签是否能被正确加载,类别数是否匹配,数据管道是否通畅。如果这一步顺利跑完,数据格式基本就没问题了。如果输出里出现标签加载警告,比如“found unknown label index”,那就回到转换脚本重新检查类别映射。
5. 常见问题与排查实录
转换过程中踩过的坑,我整理成一份速查表。这些问题基本覆盖了labelme转YOLOv8分割的绝大多数异常情况。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 训练时报标签索引越界 | 类别编号与data.yaml中names数量不一致 | 重新生成data.yaml,确认类别映射从0开始连续 |
| 某些图片没有对应TXT文件 | 原JSON里没有有效多边形,或图片路径找不到 | 检查原标注,删除无标注图片或重新标注 |
| 多边形形状扭曲、位置偏移 | 归一化时用的宽高和实际图像尺寸不一致 | 确认JSON中imageWidth/imageHeight正确,图像未缩放 |
| 类别数量比预期多 | 标注时打错字或新增了未预期的类别 | 检查classes.txt,清理错误类别名 |
| 验证集不包含某些稀有类别 | 随机划分导致稀有类别全落在训练集 | 改用分层采样,或增加验证集比例 |
| 转换后TXT坐标出现负值或大于1 | 多边形顶点超出图像边界 | 归一化时强制截断到0到1区间 |
5.1 关于空标注文件的处理
如果一张图片在labelme里被标注过一次,但所有形状都被脚本判定为无效,比如矩形只有一个点、或者类别不在映射中,脚本会把对应TXT也删掉。这个行为需要特别注意:原本图片是训练样本,转换后却因为格式问题被悄悄舍弃了。我的建议是在转换日志中记录所有被跳过的JSON,最后统计出一个清单,检查这些样本是否真的不需要。
5.2 类别顺序不稳定带来的隐患
自动收集类别看起来方便,但如果你今天转换一次,明天在标注目录里新增了一个类别,再运行脚本时,所有类别的编号可能全部重排。这意味着之前训练出的模型权重文件就废了。所以我在脚本里默认是保存classes.txt的,后续再转换时务必传入同一个文件。把类别映射当作数据资产的一部分进行管理,而不是临时生成的中间变量。
5.3 大数据集转换的加速方案
当JSON文件数量达到几千甚至上万时,单线程遍历转换会明显变慢。我实测过,约8000张图片的标注,单线程需要十分钟左右,虽然可以接受,但使用multiprocessing能缩短到两三分钟。逻辑并不复杂:将JSON文件按进程数均分,每个进程独立处理并生成TXT,最后再汇总进行划分。不过要注意,多进程下print日志会交错输出,关键信息建议统一写到一个日志文件里,而不是直接打印到终端。
6. 转换完成后的一些扩展心得
在项目推进过程中,数据转换只是漫长训练链路的一环。有几个点我觉得值得延伸思考:当数据集规模变大后,建议把转换脚本纳入版本管理,连同classes.txt和data.yaml一起提交,这样可以确保任何同事拉取代码后都能重新生成一模一样的训练数据。
关于数据增强,YOLOv8自带了不少在线增强策略,比如随机翻转、尺度变化、色彩抖动,但对于分割任务,我建议在训练初期保持默认的增强参数,先观察loss能否正常下降。如果出现严重过拟合,再缓慢加大增强强度。增强方式对模型性能的影响,最好通过对照组实验来判断,而不是凭感觉调整。
交叉验证是另一个值得尝试的方向。当我手上的标注数据少于2000张时,比起单次划分训练集和验证集,我更倾向于做5折交叉验证:重新训练五次,每次换不同的验证集,取平均指标作为模型评估结果。这样能更稳定地反映模型效果,避免因一次划分不好而误判模型质量。转换脚本中数据结构是固定的,只要把划分逻辑换一下,就能支持K折验证。
最后再分享一个习惯:每次转换之后,我都会在结果目录里生成一份转换记录文件,写入原始JSON数量、有效样本数、各类别数量、划分比例、随机种子等元信息。半年之后如果模型效果出现问题,这些信息能帮你快速定位是不是数据在转换阶段就出了差错。很多困扰人的“模型玄学”,最后查来查去,其实都是数据管线里的小毛病。
本文还有配套的精品资源,点击获取