简介:一套基于Python的Labelme标注转YOLOv8语义分割数据集的自动化工具,能读取JSON格式的标注文件,批量转换为模型训练所需的语义分割格式,并自动划分训练集与验证集,省去手动整理标注和图片的重复劳动。该工具面向人工智能、通信工程、自动化、物联网等专业的高校学生、教师及科研人员,可应用于毕业设计、课程设计、项目初期演示等场景。压缩包共14个文件,约1.95MB,其中JSON标注文件保存标注信息,JPEG/JPG图像提供测试样本,Python源码包含格式转换和训练示例脚本,Markdown文档详述环境配置与操作流程,附带的示例数据可直接运行验证。目前已有70人学习下载。源码经严格测试,功能完善且结构清晰,附有设计文档;既适合初学者对照学习,也可在基础框架上扩展自定义数据集,遇到问题可远程交流,是一份兼顾教学与实践价值的工具型资源。
1. 从 labelme 到 YoloV8 分割数据集:这活儿没有想象中那么「复制粘贴」
做语义分割的人,十有八九都有过这种经历:用 labelme 辛辛苦苦勾了一百多张图的多边形,勾完才意识到 YoloV8 要的是 txt 格式,每一行是「类别编号 + 归一化后的 x y 坐标」。两边格式差距有多大?labelme 给的是 JSON,里面存的是绝对像素坐标、图像尺寸、形状类型;YoloV8 要的是相对坐标,是一个类别的所有顶点连在一起的多边形。最头疼的是 labelme 支持画矩形、圆、线、点,而 YoloV8 分割只认多边形——你之前标注的时候要是贪图省事用了矩形框,转换的时候还得多一步把矩形变成四点多边形的逻辑。这个 zipped 方案解决的就是这条链路:解析 JSON、归一化坐标、按类别写入 txt,再顺手按比例把数据拆成训练集和验证集,全程用 Python 脚本搞定。适合谁?准备用 YoloV8 跑语义分割却卡在数据预处理上的人。换句话讲:就是那张从「标注完成」到「能跑起来 train.py」的入场券。
2. 核心转换逻辑:JSON 结构拆解与坐标归一化的数学
2.1 labelme 标注文件到底长什么样
先翻开一个 labelme 标注的 JSON 文件看结构,核心字段就那么几个:imagePath存原图文件名,imageWidth和imageHeight是原图尺寸(这个必须用,归一化全靠它),shapes是一个数组,数组里每个元素是一笔标注。每一笔标注里label是类别的名字(比如road、building、person),points是多边形的顶点列表([[x1,y1],[x2,y2],...]),shape_type是标注类型(polygon、rectangle、circle这些)。
关键点是:labelme 默认的坐标系是图像左上角为原点,x 向右、y 向下,这和 YoloV8 的归一化逻辑是完全兼容的,所以不需要翻转坐标轴。但有一个小陷阱,后面细讲——points里顶点的顺序。用 labelme 的时候你在图上按什么顺序点,顶点就按什么顺序存。YoloV8 对顶点顺序没有硬性要求,只要点是按多边形边缘连续排列的就行。但如果你标注的时候画的线是交叉的、或者从中间开始又绕回去,那转换出来的多边形就完全错位了。
解析 JSON 用json.load()打开读进来就行,不要自己写正则去抠字符串,没有任何必要。
2.2 归一化:一句话的事,细节全在边上
坐标归一化公式极其简单:归一化后的 x = 像素 x / 图像宽度,归一化后的 y = 像素 y / 图像高度。YoloV8 分割标签要求所有坐标值落在 0 到 1 之间,而且对超出图像边界的点会直接报错或者裁剪,所以转换之前还有一个边界检查要做。
我见过很多人的转换脚本里只有这一句:
x_norm = px / img_w y_norm = py / img_h写完就觉得完事了。实际上有两点必须处理:第一,浮点数精度。Python 里float默认是双精度 64 位,写文件的时候如果不做截断会写出很长一串小数。YoloV8 加载标签时用的是np.loadtxt一类的操作,小数点后六位完全够用,写文件时统一round(coord, 6)既能减小体积,也能避免因为精度太长导致的浮点误差积累。第二,顶点数过多的问题。如果你标注的建筑轮廓特别细致,一个多边形有几百个顶点,归一化后全部写进一行 txt,在训练时letterbox缩放后这些点之间可能产生微小的自交点,虽然不影响 loss 计算,但会让 mask 边缘出现细碎的锯齿。后面避坑章节会单独讲怎么抽稀。
2.3 从 JSON 到 txt:一张图对应一个标签文件的 IO 设计
这一个环节看着简单,做起来有不少讲究。最稳妥的文件命名策略是:原图文件名.jpg对应原图文件名.txt。YoloV8 源码里按image_path去推断标签路径,替换后缀就行。如果你的原图叫a.png,标签就叫a.txt。
I/O 设计上有两种思路。一种是遍历所有 JSON 文件,一个个解析、写 txt。另一种是先扫描原图文件,对每张原图去找对应的 JSON。我一般推荐第一种,明确以 JSON 为主循环:因为有可能出现「标了 JSON 但原图已经被误删」的情况,以 JSON 为准能第一时间发现;而以图片为准容易漏掉没标注的图,导致数据集里混入「没标签的负样本」,训练时直接报AssertionError: Label not found。
写文件的代码框架可以这样拆:
import json import os def is_valid_polygon(points): # 多边形至少 3 个顶点,且面积不为 0 return len(points) >= 3 and len(set(map(tuple, points))) >= 3 def convert_labelme_to_yolo(json_path, out_dir, class_name_to_id): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) img_w = data.get('imageWidth') img_h = data.get('imageHeight') if not img_w or not img_h: print(f"跳过 {json_path}: 缺少 imageWidth/imageHeight") return base_name = os.path.splitext(os.path.basename(data['imagePath']))[0] lines = [] for shape in data['shapes']: if shape['shape_type'] not in ('polygon', 'rectangle'): continue label = shape['label'].strip() if label not in class_name_to_id: print(f"警告: {base_name} 有未注册类别 {label},已跳过") continue points = shape['points'] if not is_valid_polygon(points): print(f"警告: {base_name} 中的 {label} 顶点数不足或无效") continue cls_id = class_name_to_id[label] normalized = [] for px, py in points: nx = round(px / img_w, 6) ny = round(py / img_h, 6) normalized.append((nx, ny)) coords_str = ' '.join([f"{x} {y}" for x, y in normalized]) lines.append(f"{cls_id} {coords_str}") if lines: out_path = os.path.join(out_dir, base_name + '.txt') with open(out_path, 'w', encoding='utf-8') as f: f.write('\n'.join(lines)) print(f"已生成 {out_path}")is_valid_polygon这段处理容易被忽略:labelme 里手滑点了两下就保存的情况不是没有,顶点数少于 3 的多边形写到 label 文件里,YoloV8 数据校验时会直接中断。set(map(tuple, points))是去重判断,防止同一个点被重复记录导致面积为 0。
需要注意shape_type的判断。labelme 默认标注模式画出来的是polygon,但如果你用了矩形框工具,shape_type就是rectangle,而且points只有两个顶点(左上、右下)。YoloV8 分割不认这种格式,所以要么在转换时把矩形展开成四个顶点(左上、右上、右下、左下),要么直接跳过。上面代码里跳过矩形可能不适合你的需求——如果你想保留矩形标注,可以在循环里加一段展开逻辑:
if shape['shape_type'] == 'rectangle': x1, y1 = shape['points'][0] x2, y2 = shape['points'][1] points = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]还有一点:类别映射。class_name_to_id这个字典必须和你训练配置里的data.yaml保持完全一致,否则类别就全串了。一般这么写:
class_name_to_id = { 'road': 0, 'building': 1, 'vegetation': 2, 'person': 3, }类别从 0 开始编号,YoloV8 不认从 1 开始的编号。很多人第一次跑分割训练的时候 loss 异常大,检查半天发现类别就是从 1 开始写的。这个细节能在转换阶段堵住就堵住,不要拖到训练才暴露,到时候面对几千张图的标签根本不想改。
3. 一键自动划分数据集:训练集/验证集拆分的最佳策略
3.1 为什么随机抽样还不够
拿到全部转换后的 txt 标签之后,下一步就是按比例拆训练集和验证集。这个问题听着简单,写个random.shuffle再切片就行,但实际做起来有几个隐性需求:「可复现的随机」「同一张图的图像和标签文件要分开落在不同集合」「尽量不要出现某个类别只在验证集里出现的极端情况」。
最基础的做法是打乱所有样本的序号,前 80% 进训练集,后 20% 进验证集。常见做法是先在图片这一层做划分,再把划分结果存成一份清单文件。YoloV8 的YOLODataset类支持你传两个清单文件,比如train.txt和val.txt,里面每行是一个绝对路径或相对路径,指向图片文件。所以我一般会生成两个 txt,分别列出训练和验证的图片路径,同时按另外一份映射关系把对应的标签文件也搬进两个目标目录。原因是直观——U 盘拷给别人或者换电脑训练时,眼看到的就是images/train/、images/val/、labels/train/、labels/val/这种灯光下谁都能懂的目录结构。
3.2 设置固定随机种子保证可复现
随机种子这个细节决定你的实验可不可复现。训练集验证集是随机划的,如果你的划分脚本每次跑出来结果不一样,那后面训练出来的模型性能在两个 epoch 之间对比的基线就乱了。加一行random.seed(42)是必须的,但这行代码放在整个脚本的最前面。
这里给出一个完整可用的划分脚本,它在上一章的转换之后运行,输入是转换好的全部标签文件:
import os import random import shutil from collections import defaultdict # 固定随机种子,保证每次划分结果一致 random.seed(42) img_source = "path/to/your/images" # 原图目录 label_source = "path/to/your/labels" # 转换后标签 txt 目录 train_ratio = 0.8 # 训练集比例 out_root = "dataset_yolo_seg" def gather_samples(img_dir, lb_dir): samples = [] for fname in os.listdir(lb_dir): if not fname.endswith('.txt'): continue # 过滤掉 classes.txt 这类非标签文件 if fname in ('classes.txt',): continue stem = os.path.splitext(fname)[0] img_candidates = [ os.path.join(img_dir, stem + ext) for ext in ('.jpg', '.jpeg', '.png', '.bmp') ] img_path = next((p for p in img_candidates if os.path.exists(p)), None) if img_path is None: print(f"跳过 {fname}: 找不到对应原图") continue samples.append((img_path, os.path.join(lb_dir, fname))) return samples samples = gather_samples(img_source, label_source) random.shuffle(samples) split_idx = int(len(samples) * train_ratio) train_samples = samples[:split_idx] val_samples = samples[split_idx:] def copy_to(sample_list, subset): img_out = os.path.join(out_root, "images", subset) lb_out = os.path.join(out_root, "labels", subset) os.makedirs(img_out, exist_ok=True) os.makedirs(lb_out, exist_ok=True) list_lines = [] for img_path, lb_path in sample_list: dst_img = os.path.join(img_out, os.path.basename(img_path)) dst_lb = os.path.join(lb_out, os.path.basename(lb_path)) shutil.copy2(img_path, dst_img) shutil.copy2(lb_path, dst_lb) list_lines.append(dst_img) list_file = os.path.join(out_root, f"{subset}.txt") with open(list_file, 'w') as f: f.write('\n'.join(list_lines)) copy_to(train_samples, "train") copy_to(val_samples, "val")注意几个设计选择。第一,gather_samples里以标签文件为基线去配对图片,避免有没有标签的图片混进来。第二,原图扩展名支持.jpg/.jpeg/.png/.bmp,用next()加os.path.exists的方式去探测真实文件名——因为 labelme 的 JSON 里imagePath可能有../这种相对路径,而这个脚本里你面对的是已经归拢好的图片目录,直接用扩展名探索最省事。第三,复制而不是移动,保留原始标注文件做备份。实际训练中你改了标注重新转换的情况经常发生,原始 JSON 一旦丢了,标注成本就白花了。这里特意用了copy2保留文件时间戳,如果图片很多的话,可以改成os.link或者shutil.move来省磁盘空间,但优先级不高。
3.3 类别均衡:验证集里不能没有某一类
这个点容易被忽略。按 8:2 随机抽样,如果你的数据集里某个类别本身只有 5 个样本,在 80/20 的随机划分下,这个类别全部落进训练集的概率是0.8^5 ≈ 32.8%,近三分之一的情况验证集里根本没有这个类别。验证集 mon 里只有四类,但 yolo 验证时会统计每个类别的 mAP,缺失的类直接给 0 分,看起来像模型完全没学会这个类。
处理办法是分层抽样:保证每个类别在训练集和验证集里都至少出现一次。极端情况下,某一个类别只有 1 张图,那就把它复制到两边——虽然不完全严谨,但比缺类强一个量级。实现起来不怎么复杂:
# 分层抽样: 对每个类别分别划分 by_label = defaultdict(list) for img_path, lb_path in samples: with open(lb_path, 'r') as f: first_token = f.readline().split()[0] by_label[first_token].append((img_path, lb_path)) train_set, val_set = [], [] for label, items in by_label.items(): random.shuffle(items) n_train = max(1, int(len(items) * train_ratio)) # 如果样本太少,保证至少一个进验证集 if len(items) - n_train == 0: n_train -= 1 train_set.extend(items[:n_train]) val_set.extend(items[n_train:])这段的思路是:按每个 txt 第一列类别编号把样本分组,同组的才随机划分。max(1, ...)保证每类至少一张进训练集,if len(items) - n_train == 0保证每类至少一张进验证集。对有 2000 张以上的大数据集,这种写法和纯随机划分差别不大;但样本量只有一两百张时,不这样做验证集的指标会忽高忽低,完全是抽样偏差导致,不是模型好坏。
4. 转换后必做的一步:用黑图和可视化检查「转换对没对」
转换脚本跑完,不要直接开训,你需要用肉眼验证一遍。分割标签的转换错误不像检测框那样有「画框」可以直接对比——它是像素级掩码,错了很难直接看出来,训练的时候 loss 又能正常下降。所以,可视化这一步请养成肌肉记忆。
我在转换脚本里会顺带生成一个验证图。每张标注图对应用两种颜色的叠加:黑色底图上画白色多边形轮廓,或者直接在原图上叠加半透明的轮廓。这里给一个用 OpenCV 实现的最小验证脚本,核心是还原 labelme 原始标注,把多边形画出来:
import cv2 import numpy as np def visualize_mask(img_path, label_path, class_colors, out_path): img = cv2.imread(img_path) h, w = img.shape[:2] overlay = img.copy() with open(label_path, 'r') as f: for line in f: parts = line.strip().split() if len(parts) < 7: # 至少要 class + 3 个点 continue cls_id = int(parts[0]) coords = np.array(parts[1:], dtype=np.float32).reshape(-1, 2) coords[:, 0] *= w # 反归一化回像素坐标 coords[:, 1] *= h coords = coords.astype(np.int32) color = class_colors.get(cls_id, (0, 255, 0)) cv2.fillPoly(overlay, [coords], color) cv2.polylines(overlay, [coords], True, (255, 255, 255), 2) # 半透明混合 alpha = 0.5 result = cv2.addWeighted(overlay, alpha, img, 1 - alpha, 0) cv2.imwrite(out_path, result)这脚本干了一件事:把 YoloV8 格式的 txt 标签反算回像素坐标,再画到原图上。注意coords[:, 0] *= w的写法,就是把归一化的坐标乘回宽度和高度。跑完以后随机抽查 10 到 20 张,视线集中在两个位置:轮廓是否贴合物体的边缘,以及类别颜色是否和物体匹配——尤其是题图上标注的不同类别挨得很近或互相遮挡的场景。
我遇到过一种典型翻车:把多边形的顶点顺序搞反,fillPoly画出来的掩码呈自交星形。视觉检查一眼就暴露。而如果不做这一步直接开训,模型会在某些区域学出诡异的纹理,损失曲线看着正常,推理出来的掩码边缘全乱。
另一个建议是顺带生成一个dataset_stats.txt,统计每张标签文件的顶点总数、类别分布。这样你能在看不到图的情况下判断脚本输出的数据整体是不是健康。比如某张图的标签里多边形顶点数突然从 200 涨到 2000,说明那笔标注可能手滑了。
5. 转换流程避坑:5 个最常踩的坑和对应的处理办法
5.1 labelme 图像路径带中文,cv2 读图失败
现象:脚本跑了一半,报cv2.error: OpenCV(4.x) ... imread_错误,或者读出来的img是None,后面访问img.shape直接抛异常。通常发生在 Windows 上,中文用户名或者中文目录名。
原因:OpenCV 的imread底层用的是 C++ 标准库的文件读取,对 UTF-8 编码的中文路径支持不好。而 Python 原生的open()函数没这个问题,所以去读 JSON 文件没问题,一到cv2.imread就炸。
解决:不要用cv2.imread读图,改用np.fromfile加cv2.imdecode:
def cv_imread(file_path): data = np.fromfile(file_path, dtype=np.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)同理,cv2.imwrite写中文路径也会失败,改成cv2.imencode('.png', img)[1].tofile(out_path)。如果你整套转换逻辑里根本不去读原图像素(只从 JSON 拿宽高),那就不需要改。但一旦加了可视化这一步,上面这段就是必踩的坑,血泪经验,提前写进自己的工具函数里。
5.2 归一化后坐标等于 1.0,训练时 RLE 编码报 index out of range
现象:训练跑到一半,终端打印IndexError: index 256 is out of bounds for axis 0 with size 256,或者类似的 RLE 编码越界错误。但你检查 txt 文件觉得没问题。
原因:某个多边形的顶点坐标恰好落在图像最右侧或最下侧的像素上,比如点的坐标就是imageWidth本身(注意像素坐标从 0 开始,最后一个像素的坐标是width - 1)。归一化后这个点的 x 等于1.0。YoloV8 内部做 mask 编码时,会把x * width映射回像素网格,1.0 * 256得到了256,而网格的索引范围是0..255。越界就崩了。
解决:归一化后做一次裁剪,把所有坐标强制限制在[0.0, 0.999999]范围内。加两行代码:
nx = min(max(round(px / img_w, 6), 0.0), 0.999999) ny = min(max(round(py / img_h, 6), 0.0), 0.999999)这属于典型的玄学报错,训练中断后第一反应普遍是去查数据集目录结构,很少有人会想到是某个坐标多了一个像素。
5.3 导出的 txt 里混进了 labelme 自带的填充图像
现象:转换后数据集目录里莫名其妙多了一堆_json文件夹或img.png文件。训练时Dataset not found或者标签列表里全是报错。
原因:labelme 有个「导出」功能,会为每张标注图生成一个以文件名_json命名的目录,里面存放转换后的可视化图像(img.png是原图拷贝,label.png是掩码图)。如果你把整个标注目录直接当输入扔给转换脚本,脚本遍历文件时把这些 PNG 也当成原图拉进来了。
解决:两个办法。转换脚本里用后缀白名单过滤,只处理.json文件;划分数据集时,跳过所有路径里含_json的文件。如果已经混进去了,删掉再重转,不要自己写清理脚本——因为_json目录下的文件还有重名的可能。宁可这次多花 30 秒重新拷贝一遍原始标注目录,不要在脏数据上修修补补。
5.4 多边形顶点太密集,生成的 mask 边缘锯齿严重
现象:转换出来的分割效果图轮辋边缘有密集的小锯齿,训练后模型推理结果噪点很多。尤其标注时用多边形一点点描非常曲折的物体(比如树叶轮廓),一个多边形可能有 2000 多个点。
原因:YoloV8 在训练时会把多边形按尺寸缩放到640x640再栅格化成 mask,顶点太密不仅占存储,缩放时相邻点之间距离小于一个像素,会产生大量无效顶点。
解决:用 Douglas-Peucker 抽稀算法。shapely库里自带simplify方法,或者手工实现:
def simplify_polygon(points, tolerance=2.0): from shapely.geometry import Polygon poly = Polygon(points) simplified = poly.simplify(tolerance, preserve_topology=True) if simplified.geom_type == 'Polygon': return list(simplified.exterior.coords)[:-1] # 去重闭合点 return points # 简化失败就保留原样tolerance建议设在 1.0 到 2.0 像素之间,太小没效果,太大会把尖角削平。特别注意preserve_topology=True这个参数不能丢,否则简化后的多边形可能发生自相交。
5.5 划分后 labels 目录里混进了classes.txt导致 YoloV8 报错
现象:训练开始后报RuntimeError: Dataset 'xxx' error ...,检查数据集目录结构没错,图片文件在、标签文件在、data.yaml路径也对。
原因:用 labelme 导出的文件里,除了每张图的 JSON,可能还有一个classes.txt记录了全部类别名。你写gather_samples时如果只判断.txt后缀,就会把classes.txt当成标签文件拷进 labels 目录。YoloV8 加载时读到这个文件,解析第一行发现是字符串而不是数字,直接报错。
解决:转换脚本里显式排除classes.txt(前面代码已有),并且在划分函数里加一道白名单过滤,只允许文件名和图片文件主名一致的 txt 进入最终目录。如果已经污染了,直接删掉标签目录下的 classes.txt 再重跑。
6. 最后的收尾习惯:转换脚本本身也是产物,写进你的仓库
到这里,转换和划分的完整流程已经走通了。但我说句实在话——真正让你下次节省时间的,不是记住这些代码怎么写,而是把转换脚本固定成一个可复用的命令行工具,跟数据集放一起放进仓库。下次拿到一批新的 labelme 标注数据,跑一条命令,出来完整可训练的数据集,不需要打开编辑器改路径。
我习惯把这个脚本做成convert_and_split.py,命令行参数这样组织:
python convert_and_split.py \ --json_dir ./labelme_annotations \ --image_dir ./raw_images \ --out_dir ./yolo_seg_dataset \ --class_file ./classes.txt \ --train_ratio 0.8 \ --seed 42 \ --visualize其中--visualize是白开关,默认关闭,开启时每次转换随机抽 5 张生成可视化检查图存到out_dir/visual_check/。用命令行参数而不是改代码里的常量,最大的好处是——你三周后回来看这条命令,不用逐一回忆代码逻辑就能复现完整流程。脚本本身要记得同时放到git仓库里,和数据集版本一起管理。
验证工作没做完之前不要开训练。怎么算验证做完了?第一条,随机抽 20 张visual_check的图肉眼翻一遍,确认多边形贴合物体边缘、类别颜色无错位;第二条,确认train.txt和val.txt文件加起来等于全部样本数,没有遗漏;第三条,训练起来第一个 epoch 的box_loss和seg_loss在正常范围下降——语义分割的seg_loss初始值一般显著低于检测的box_loss,如果一开始就飞了,先回来查数据。
说到底,从 labelme 到 YoloV8 的转换不是一个高深的技术活,但它是个拼细节的体力活——细节全在那些「看起来没问题,一训练就翻车」的边界条件里。把前面那五个坑全部提前堵死,后面就只剩跑脚本、看曲线、调参数这些正事了。这也是我做这类数据工具的习惯:一次写完整,永远可复现,希望帮到你。
本文还有配套的精品资源,点击获取