从Labelme到YOLOv8分割:转换脚本与数据集划分实战
简介本资源是一套面向计算机视觉初学者与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, encodingutf-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, encodingutf-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, encodingutf-8) as f: f.write(\n.join(label_lines) \n) return out_txt def main(): parser argparse.ArgumentParser(descriptionlabelme JSON to YOLOv8 segmentation dataset) parser.add_argument(--json_dir, requiredTrue, helplabelme JSON files directory) parser.add_argument(--image_dir, requiredTrue, helporiginal images directory) parser.add_argument(--output_dir, defaultyolo_dataset, helpoutput dataset directory) parser.add_argument(--val_ratio, typefloat, default0.2, helpvalidation set ratio) parser.add_argument(--seed, typeint, default42, helprandom seed) parser.add_argument(--classes_file, default, helpoptional 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, encodingutf-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), encodingutf-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_okTrue) # 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 fpath: {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, encodingutf-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)], dtypenp.int32) cv2.polylines(img, [pts], isClosedTrue, color(0, 255, 0), thickness2) 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按类别记录训练集和验证集的数量。表格形式方便直观比较。比如下面的结构类别训练集数量验证集数量person18245car9623traffic_light216如果发现某个类别在验证集中数量过少一个补救方案是用分层采样替代纯随机划分也就是按类别比例来分配训练和验证样本。这也是为什么我建议在收集有效文件时保留每个文件包含的类别信息后续做分层划分会方便很多。4.3 用模型试跑一个epoch验证数据可用性这是最有效的验证手段。在确认目录结构和标签格式没问题后直接启动一个极简训练yolo train modelyolov8n-seg.pt dataseg_dataset/data.yaml epochs1 imgsz640一个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数量、有效样本数、各类别数量、划分比例、随机种子等元信息。半年之后如果模型效果出现问题这些信息能帮你快速定位是不是数据在转换阶段就出了差错。很多困扰人的“模型玄学”最后查来查去其实都是数据管线里的小毛病。本文还有配套的精品资源点击获取