ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Labelme JSON转YoloV8分割标签:完整转换脚本与实战指南

Labelme JSON转YoloV8分割标签:完整转换脚本与实战指南 简介本资源是一套面向计算机视觉初学者与YOLO模型实践者的自动化工具集专为解决labelme标注数据向YOLOv8语义分割格式转换及数据集划分的痛点而设计。资源包含14个文件5个labelme生成的JSON标注文件、4张JPEG/JPG图像样本、2个核心Python脚本convert_folder.py与train_example.py、1份README说明文档总大小仅1.95MB轻量易部署。已有1294人学习下载反映出其在实际项目预处理环节中的高频需求。用户可直接运行脚本完成多边形标注→YOLOv8分割标签归一化坐标类别ID的精准转换并支持按比例自动划分训练集与验证集目录结构清晰examples文件夹内置完整标注-图像配对样本便于快速验证流程配套README提供参数说明与使用指引显著降低YOLOv8语义分割任务的数据准备门槛。1. 数据格式差异labelme JSON 与 YoloV8 分割标签的“前世今生”1.1 labelme 标注文件到底长什么样如果你用 labelme 做过标注肯定熟悉这种流程打开 labelme - 打开图片目录 - 用多边形工具沿着目标边缘点一圈 - 命名标签 - 保存。保存后每张图片对应生成一个同名的.json文件里面记录的不只是轮廓点还塞了一堆附加信息。我直接贴一段真实标注文件的结构方便还没仔细看过的朋友心里有个底{ version: 5.2.1, flags: {}, shapes: [ { label: car, points: [ [312.5, 204.4], [340.1, 205.0], [368.7, 207.9], [395.2, 220.4] ], group_id: null, shape_type: polygon, flags: {} }, { label: road, points: [ [10.0, 400.1], [300.3, 380.6], [500.0, 430.2], [600.0, 470.0] ], group_id: null, shape_type: polygon, flags: {} } ], imagePath: frame_001.jpg, imageData: /9j/4AAQSkZJRgABAQAAAQABAAD..., imageHeight: 720, imageWidth: 1280 }几个关键字段你得心里有数shapes核心数组里面每一个元素就是一个标注对象。shapes[].label类别名比如car、road、person。shapes[].points多边形顶点列表单位是像素坐标。注意labelme 保存的是绝对像素坐标也就是原图上真实的 x、y 值。shapes[].shape_type标注类型最常见的是polygon但也会有rectangle、circle、line、point这些。做语义分割时只用polygon后面这一块有坑我专门会讲。imagePath对应图片的文件名转换时要靠它找到原图。imageHeight/imageWidth原图尺寸归一化坐标时要用。imageData图片的 Base64 编码字符串。这是 labelme 的一个“贴心”设计把图片直接内嵌进 JSON 里方便单文件传输。但如果标注时用了--nodata参数这个字段就是空的转换脚本得能兼容这两种情况。1.2 YoloV8 语义分割标签的格式约定YoloV8 做语义分割用的标签不是 PNG 掩码图也不是 COCO 那种 JSON而是延续了 YOLO 家族一直以来的“一个物体一行文本”的思路只是坐标从矩形框换成了多边形点串。每个.txt文件内容和图片同名一行代表一个目标实例0 0.524 0.301 0.531 0.302 0.539 0.301 0.544 0.305 ...每一行的格式是类别ID x1 y1 x2 y2 x3 y3 ...这里的坐标是归一化坐标也就是用实际像素坐标分别除以图片宽和高取值范围在 0 到 1 之间。为什么要归一化因为模型训练时不管输入图片是 640x640 还是 1280x720坐标都在一个固定尺度范围内模型更好学也不用担心不同分辨率图片带来的尺度差异。需要特别注意的是归一化的 x 坐标除以图像宽度归一化的 y 坐标除以图像高度千万别搞反也别统一除以一个值。图片通常是矩形的宽高不相等除以同一个值会让目标形状在水平或垂直方向上被拉伸模型训练时看到的形变目标会和真实目标不一致精度会明显下降。另外一点YoloV8 分割标签里的类别 ID 是从 0 开始的整数。比如你的数据集有background、car、road三类那背景就是 0car 是 1road 是 2。具体 ID 对应的类别靠一个dataset.yaml文件来定义names: 0: background 1: car 2: road目录结构方面YoloV8 期望的是这样的组织方式dataset/ ├── images/ │ ├── train/ │ │ ├── frame_001.jpg │ │ └── ... │ └── val/ │ ├── frame_100.jpg │ └── ... └── labels/ ├── train/ │ ├── frame_001.txt │ └── ... └── val/ ├── frame_100.txt └── ...图片和标签严格同名后缀分别是.jpg也可能是.png和.txt分布在训练和验证两个子目录里。训练时配置文件里指定train和val的图片路径即可YoloV8 会自动去对应的labels目录找同名 txt。1.3 为什么不能拿来直接用很多人一开始会想我能不能写个简单的 DataLoader训练的时候直接解析 labelme 的 JSON 喂给模型理论上可以但实际上非常不推荐。原因有几个第一YoloV8 官方训练流程从数据加载、数据增强Mosaic、仿射变换等、损失计算到评估指标全都默认你用的是它的标签格式。你如果自定义数据加载器这些模块全都要跟着改工程量巨大不说还容易在增强阶段把多边形变换搞错导致训练崩掉。第二labelme 的 JSON 里图片以 Base64 形式嵌入每次读取都要解码一遍数据加载速度被拖慢。训练集几千张图时这个额外开销不是可以忽略不计的。第三自己写数据加载器属于“自找麻烦且容易出错”的路线。官方做了现成的格式支持稳稳当当把数据转成标准的 YoloV8 格式然后用官方 YAML 配置文件直接开训省心又稳妥。整个转换脚本用纯 Python 标准库就能完成根本不需要安装一堆重型依赖。所以在动手写转换脚本之前先彻底明白这两种格式的差异你才能理解脚本里每一行代码到底在干嘛。2. 转换脚本的核心设计与完整代码2.1 环境准备只需 Python 标准库就够了这个脚本的亮点之一就是不需要安装任何第三方库只要你的电脑上有 Python 3.7 以上的环境直接就能跑。为什么能做到这点因为我们要处理的 JSON 解析用json库文件操作用os、shutil库随机划分用random库就连解码 Base64 图片数据也只需要base64标准库。不过我还是建议你在本地新建一个干净的工作目录里面放一个class_mapping.json文件用来定义类别映射关系。为什么不直接在脚本里写死一个字典因为实际项目里类别经常会调整放在独立配置文件里以后增删类别不用改代码只改这个 JSON 就行更加灵活。2.2 转换前必须整理的类别映射表类别映射是整个转换流程的“总闸门”直接影响每行标签开头的那个数字 ID。我在做转换前的第一件事就是打开 labelme 标注目录里的随便几个 JSON把所有出现过的类别名拉出来排个序。比如我检查后发现标注文件里出现了这些类别background、car、person、road、tree。那我的class_mapping.json就这样写{ background: 0, car: 1, person: 2, road: 3, tree: 4 }这里有两个建议background 尽量放在 0 号位。虽然 Pytorch 默认的交叉熵损失会把ignore_index设为 255YoloV8 在语义分割任务里也有自己处理背景的方式但把背景放在 0 号位是业内通用习惯能避免很多后续调试时的困惑。类别名一定不要拼错。脚本是按字符串精确匹配来找类别的如果 JSON 里的的标签是Car大写 C而映射表里写的是car小写脚本就会直接报错。我建议在脚本里加一段检查逻辑列出所有在 JSON 中出现但不在映射表里的类别名方便排查拼写差异。2.3 核心转换函数与完整代码下面这是我在实际项目里封装好的转换脚本做了比较完善的兼容处理。你可以直接复制去修改路径然后跑起来。import json import os import shutil import random import base64 from pathlib import Path def load_mapping(mapping_path): 加载类别映射表并同时生成反向映射供检查用。 with open(mapping_path, r, encodingutf-8) as f: mapping json.load(f) return mapping def read_image_from_json(labelme_json): 从 JSON 中提取图片数据。 优先使用 imageData 的 Base64 字段如果没有则去相同目录找同名图片文件。 img_data labelme_json.get(imageData, ) if img_data: return base64.b64decode(img_data) img_path Path(labelme_json[imagePath]) # 如果 imagePath 是绝对路径需要取 basename img_file img_path.name json_dir labelme_json.get(_json_dir, ) full_img_path Path(json_dir) / img_file if not full_img_path.exists(): raise FileNotFoundError(f找不到图片: {full_img_path}) return None # 不需要提前读后面复制文件就行 def convert_single_json(json_path, labelme_root, output_root, mapping, copy_imagesTrue): 转换单个 labelme JSON 文件为 YoloV8 分割标签并复制对应图片。 with open(json_path, r, encodingutf-8) as f: data json.load(f) # 记录 json 所在目录方便读取外部图片 data[_json_dir] str(json_path.parent) img_w data[imageWidth] img_h data[imageHeight] if not img_w or not img_h: raise ValueError(fJSON 文件 {json_path} 缺少 imageWidth/imageHeight 字段) # 准备输出文件名保持与图片同名 image_filename Path(data[imagePath]).name base_name Path(image_filename).stem txt_filename base_name .txt # 解析所有 polygon写入 txt lines [] skip_reasons [] for shape in data[shapes]: label shape[label].strip() shape_type shape.get(shape_type, polygon) # 只处理 polygon其他类型跳过并记录原因 if shape_type ! polygon: skip_reasons.append(f{shape_type}:{label}) continue if label not in mapping: skip_reasons.append(f未映射类别:{label}) continue points shape[points] if len(points) 3: skip_reasons.append(f点数不足:{label}) continue class_id mapping[label] # 归一化坐标保留 6 位小数足够 norm_points [] for x, y in points: nx round(x / img_w, 6) ny round(y / img_h, 6) # 防止归一化后出现 1.0 这种越界值YoloV8 训练时对边界敏感 nx min(max(nx, 0.0), 1.0) ny min(max(ny, 0.0), 1.0) norm_points.extend([str(nx), str(ny)]) line f{class_id} .join(norm_points) lines.append(line) # 一个文件里可能没有任何可用的 polygon if not lines: return False, skip_reasons # 写标签文件 out_txt_dir Path(output_root) / labels / all out_txt_dir.mkdir(parentsTrue, exist_okTrue) with open(out_txt_dir / txt_filename, w, encodingutf-8) as f: f.write(\n.join(lines) \n) # 复制图片或保留原路径引用 if copy_images: src_img Path(data[_json_dir]) / Path(data[imagePath]).name if not src_img.exists(): raise FileNotFoundError(f图片不存在: {src_img}) out_img_dir Path(output_root) / images / all out_img_dir.mkdir(parentsTrue, exist_okTrue) shutil.copy2(src_img, out_img_dir / image_filename) return True, skip_reasons def main(labelme_root, output_root, mapping_path, train_ratio0.8, random_seed42): # 1. 加载类别映射 mapping load_mapping(mapping_path) # 2. 遍历所有 JSON 文件 json_files sorted(list(Path(labelme_root).rglob(*.json))) if len(json_files) 0: raise RuntimeError(f目录 {labelme_root} 下没有找到任何 .json 文件) print(f共发现 {len(json_files)} 个 labelme JSON 文件) # 3. 全部转换 converted [] failed [] all_skip_reasons {} for json_path in json_files: ok, reasons convert_single_json(json_path, labelme_root, output_root, mapping) if ok: converted.append(json_path) else: failed.append(json_path) for reason in reasons: all_skip_reasons[reason] all_skip_reasons.get(reason, 0) 1 print(f转换成功: {len(converted)} 个跳过: {len(failed)} 个) for reason, cnt in all_skip_reasons.items(): print(f 跳过原因 {reason}: {cnt} 次) # 4. 对转换成功的文件进行训练集/验证集划分 random.seed(random_seed) random.shuffle(converted) val_count int(len(converted) * (1 - train_ratio)) val_set set(converted[:val_count]) train_set set(converted[val_count:]) # 5. 构建 YoloV8 期望的目录结构 for split_name, split_set in [(train, train_set), (val, val_set)]: img_split_dir Path(output_root) / images / split_name lbl_split_dir Path(output_root) / labels / split_name img_split_dir.mkdir(parentsTrue, exist_okTrue) lbl_split_dir.mkdir(parentsTrue, exist_okTrue) for json_path in split_set: base_name Path(json_path).stem # 图片和标签都在 all 目录里这里用软链接或复制 img_src Path(output_root) / images / all / f{base_name}.jpg if not img_src.exists(): img_src Path(output_root) / images / all / f{base_name}.png lbl_src Path(output_root) / labels / all / f{base_name}.txt img_dst img_split_dir / img_src.name lbl_dst lbl_split_dir / lbl_src.name if not img_dst.exists(): shutil.copy2(img_src, img_dst) if not lbl_dst.exists(): shutil.copy2(lbl_src, lbl_dst) print(f训练集: {len(train_set)} 张验证集: {len(val_set)} 张) print(f输出目录: {output_root}) if __name__ __main__: main( labelme_root./labelme_data, # 你的 labelme 标注目录 output_root./yolo_dataset, # 输出根目录 mapping_path./class_mapping.json, train_ratio0.8, random_seed42 )2.4 关键代码逻辑逐段拆解这个脚本看起来不长但里面有几个细节是我反复踩坑之后加进去的单独拿出来讲一讲。第一个是图片读取的双保险机制。read_image_from_json函数优先从imageData字段拿 Base64 数据但实际使用中我发现很多人标注完以后会把 JSON 里体积很大的imageData手动清掉来减小文件体积或者用--nodata模式标注。这两种情况下imageData都是空的。所以脚本里加了_json_dir这个路径记录在找不到内嵌图片时去同目录找同名的 jpg/png 文件。第二个是坐标归一化的边界处理。归一化之后理论上坐标应该在 0 到 1 之间但多边形顶点如果恰好落在图片边缘比如 x 的值等于图片宽度 1280那x / 1280 1.0。虽然 1.0 在数学上是合法的但实际训练时某些数据增强操作可能会对边界坐标做偏移导致轻微越界。所以我手动做了min(max(value, 0.0), 1.0)的钳制处理把越界风险降到最低。第三个是空标签文件的处理。如果一个 JSON 里所有 shape 都不是有效的 polygon或者类别映射表里没有对应类别那这个文件就不应该进入数据集。脚本返回False并在最后统一打印跳过原因方便你一眼看出问题出在哪。第四个是复制图片时区分 jpg/png 的逻辑。有时候标注的图片是 PNG 格式脚本里先试.jpg再试.png如果两种后缀都没有说明原图丢了。这种“懒人写法”虽然不严谨但在实际项目中能覆盖 95% 的情况。3. 训练集/验证集自动划分别把数据泄漏带进训练流程3.1 划分比例怎么定很多人以为训练集和验证集比例是固定的 8:2其实这个比例要根据数据总量来灵活调整。如果是小型数据集100 张以内建议 9:1 甚至 95:5。因为验证集太小的话评估出来的指标方差会很大可能这次随机划分让模型运气好验证精度虚高下次划分运气差精度又暴跌。你说不准模型到底学得好不好。如果是中型数据集几百到几千张8:2 是稳妥选择。验证集足够大指标稳定可靠。如果是几万张的大数据集可以放宽到 95:5 甚至 99:1。因为即便只有 1% 的验证集也有几百张图完全够用了。这种情况多留点数据给训练集反而更划算。另外随机种子一定要固定。random_seed42这个参数不是随便写的它的作用是保证每次运行脚本划分结果完全一致。这样别人复现你的实验或者你调了几天参数后想回退都不会因为数据集划分不同而导致结果对不上。3.2 按文件粒度随机划分的坑数据划分有个很容易被忽视的原则同一张图不能同时出现在训练集和验证集里。听起来像废话但在某些情况下确实会发生。比如你的标注数据是用视频抽帧得来的相邻两帧内容高度相似按文件粒度随机划分后这两帧可能一个进了训练集、一个进了验证集。这时候验证集的指标就没有参考价值了因为模型已经“见过”了几乎一样的内容。我做过一个无人机航拍项目数据是从 30 分钟的飞行视频里隔几秒抽一帧再用 labelme 标注的。刚开始直接随机划分验证精度高得离谱但上线后真实场景效果差得离谱。最后排查发现就是数据泄漏——相邻帧太像了。解决思路有两种采集数据时按“场景片段”划分同一个片段的帧要么全进训练集要么全进验证集。如果你的数据就是从不同文件夹收集的可以按文件夹划分而不是按单个文件划分。我脚本里用的是简单随机划分适合大多数情况如果你也有这种时序数据建议在main函数里把 shuffle 逻辑改成按某种分组键进行。3.3 带类别约束的划分方法还有一个常见的进阶需求每个类别在训练集和验证集里的比例应尽量一致。比如你有 100 张图其中 90 张里面有car只有 10 张里面有ship。简单随机划分后可能 8 张带ship的图都进了训练集验证集里只剩 2 张带ship模型在ship上的验证精度基本是瞎猜。我这边封装了一个按类别分层抽样的函数用起来效果比较好import collections import random def split_by_class_stratified(converted_files, train_ratio0.8, random_seed42): 按标签中的类别分布进行分层抽样划分。 random.seed(random_seed) # 记录每个文件包含哪些类别 file_classes {} for json_path in converted_files: base_name json_path.stem txt_path Path(output_root) / labels / all / f{base_name}.txt if not txt_path.exists(): continue with open(txt_path, r, encodingutf-8) as f: classes set(line.split( )[0] for line in f if line.strip()) file_classes[json_path] classes # 按类别分组每个类别分别分配 train/val assigned set() train_set set() val_set set() # 把所有类别聚合起来每个文件可能出现在多个类别分组里 # 这里用贪心策略先处理低频类别确保它们被合理分配到两个集合 class_files collections.defaultdict(list) for fpath, classes in file_classes.items(): for cls in classes: class_files[cls].append(fpath) # 按类别出现频率排序低频优先处理 for cls, files in sorted(class_files.items(), keylambda x: len(x[1])): unassigned [f for f in files if f not in assigned] random.shuffle(unassigned) val_count max(1, int(len(unassigned) * (1 - train_ratio))) # 如果某个类别特别少1~2 个文件至少保留 1 个在验证集 for f in unassigned[:val_count]: if f not in val_set: val_set.add(f) assigned.add(f) for f in unassigned[val_count:]: if f not in test_set: train_set.add(f) assigned.add(f) # 剩余的未分配文件按普通随机划分 remaining [f for f in converted_files if f not in assigned] random.shuffle(remaining) val_remaining int(len(remaining) * (1 - train_ratio)) val_set.update(remaining[:val_remaining]) train_set.update(remaining[val_remaining:]) return train_set, val_set这个分层抽样函数的核心思路是先把稀有类别文件妥善分配到两个集合再处理常见类别。这样即使某个类别只有 3 个样本也能保证至少 1 个留到验证集里。不过说实话如果某个类别样本量实在太小比如只有 1 个那不管怎么划分验证集对这个类别的评估都不可靠。这种情况我建议先别急着训练多采集一点数据才是正解。3.4 划分后目录结构长什么样脚本执行完成后output_root目录下会出现完整的 YoloV8 标准目录结构yolo_dataset/ ├── images/ │ ├── train/ │ │ ├── frame_001.jpg │ │ ├── frame_002.jpg │ │ └── ... │ └── val/ │ ├── frame_087.jpg │ └── ... ├── labels/ │ ├── train/ │ │ ├── frame_001.txt │ │ ├── frame_002.txt │ │ └── ... │ └── val/ │ ├── frame_087.txt │ └── ... ├── images/ │ └── all/ # 中间产物存放所有转换后的文件 └── labels/ └── all/ # 中间产物存放所有转换后的标签当这个结构就绪后你只需要再写一个dataset.yamlpath: /absolute/path/to/yolo_dataset train: images/train val: images/val names: 0: background 1: car 2: person 3: road 4: tree然后用命令行直接开训yolo tasksegment modetrain modelyolov8n-seg.pt datadataset.yaml epochs100 imgsz640到这一步从标注数据到 YoloV8 训练输入的一条龙流程就彻底打通了。4. 转换结果检查不做可视化等于白转4.1 用 OpenCV 叠加标注检查转换完成后我强烈建议你做一个可视化检查。这个步骤不能省因为坐标转换、类别映射、目录划分任何一个环节出了小错误光看转换日志根本看不出来。可视化检查的原理很简单读取原图把 txt 里的归一化坐标还原成像素坐标用 OpenCV 把多边形画在原图上然后和 labelme 里的原始标注做对比。我写了一个简单的检查脚本import cv2 import numpy as np import os from pathlib import Path def visualize_yolo_seg(image_path, label_path, class_names, output_pathNone): 把 YoloV8 txt 标签可视化并叠加在原图上。 img cv2.imread(str(image_path)) img_h, img_w img.shape[:2] with open(label_path, r, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) 7: # 至少 class_id 6 个坐标点 continue cls_id int(parts[0]) points np.array([float(p) for p in parts[1:]]).reshape(-1, 2) # 还原为像素坐标 points[:, 0] * img_w points[:, 1] * img_h points points.astype(np.int32) color (0, 255, 0) # BGR 绿色 cv2.polylines(img, [points], isClosedTrue, colorcolor, thickness2) cv2.putText(img, class_names.get(cls_id, str(cls_id)), tuple(points[0]), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) if output_path: cv2.imwrite(str(output_path), img) else: cv2.imshow(check, img) cv2.waitKey(0) cv2.destroyAllWindows()这个脚本渲染出来的效果应该和 labelme 打开原图时看到的轮廓基本一致。我在实际项目里一般会抽取训练集和验证集各 10 张图跑一遍重点检查这几个方面轮廓是否和目标边缘对齐有没有发生明显的偏移。有没有丢失某些目标特别是小目标。类别名是否正确显示在轮廓旁边。多边形的点是否足够密集太稀疏的话轮廓会显得粗糙。4.2 类别分布统计可视化检查通过后我还会做一个类别分布统计。这一步能帮你发现数据集的整体健康度import collections from pathlib import Path def count_class_distribution(label_dir): 统计标签目录中每个类别的目标数量。 counter collections.Counter() total_files 0 for txt_file in Path(label_dir).glob(*.txt): total_files 1 with open(txt_file, r, encodingutf-8) as f: for line in f: if line.strip(): cls_id line.strip().split( )[0] counter[cls_id] 1 print(f总文件数: {total_files}) for cls_id in sorted(counter.keys()): print(f类别 {cls_id}: {counter[cls_id]} 个目标) if __name__ __main__: count_class_distribution(./yolo_dataset/labels/train) count_class_distribution(./yolo_dataset/labels/val)这个统计结果能帮你发现不少问题。典型的情况是某个类别目标数量特别少比如总共只有 5 个实例。这时候模型很难学好这个类别你需要考虑是否要多采集一些该类别的数据是否要用数据增强来扩大该类别样本量如果实在没数据是否考虑暂时去掉这个类别4.3 检查 labelme 文件里的隐藏坑可视化检查和类别统计做完最后一个检查点是 labelme 文件本身。我在处理一个真实项目时遇到过一个很奇怪的现象某些图片在 labelme 里打开一切正常但转换后的标签训练时 loss 一直不降。排查了好久最后发现那几张图片的imageWidth字段和实际图片分辨率不一致。可能是标注过程中图片被缩放或者预处理过导致像素坐标相对尺寸不准。所以我在转换脚本里特意加了图片分辨率校验逻辑用 OpenCV 读取原图的实际宽高和 JSON 里的imageWidth/imageHeight做对比不一致就打警告。这个检查很有用。你也可以在脚本里加import cv2 def check_image_size_consistency(img_path, json_w, json_h): img cv2.imread(str(img_path)) if img is None: return False actual_h, actual_w img.shape[:2] if actual_w ! json_w or actual_h ! json_h: print(f警告: {img_path} 实际尺寸 ({actual_w}x{actual_h}) f与 JSON 记录 ({json_w}x{json_h}) 不一致) return False return True如果你的标注过程正常没有中途缩放图片这个检查一般不会触发。但一旦触发了请一定重视这往往意味着某些图片的坐标已经失真需要回到标注阶段重新处理。5. 实测中踩过的坑与处理记录5.1 同一张图出现两个同名标注文件我在处理一个从团队协作平台导出的数据集时发现同一个图片居然对应两个不同版本的 JSON 文件。比如frame_001.json和frame_001_modified.json后者的imagePath字段还是frame_001.jpg。这种情况下脚本遍历所有 JSON 时会把两个文件都转换一遍生成同名的frame_001.txt。如果两个版本标注不一致后写进去的会覆盖先写的最后的标签取决于文件的遍历顺序非常不可控。我的处理方式是在转换前先检查所有 JSON 里imagePath的 basename 是否有重复如果有就打印出来手动确认要保留哪个版本。5.2 shape_type 不只 polygon 一种labelme 里可以用矩形框、圆形、线段、点等多种方式标注但对于语义分割来说只有 polygon 多边形数据是有意义的。如果你不小心在标注时用了矩形工具那转换时就需要特殊处理——矩形只有左上和右下两个点直接写成 txt 的话只有 4 个坐标值YoloV8 训练时这个目标实例会退化成一个三角形其实不会因为 YoloV8 多边形点数量不定矩形标注只有 2 个点可能会被训练逻辑忽略或报错。我在脚本里遇到非 polygon 类型时直接跳过并记录跳过原因来统计数量。但这里有个值得注意的点如果你画矩形框的时候是希望快速打一个粗标注后续再精修那么转换前最好回到 labelme 里把这些矩形改回 polygon。偷懒直接跳过的后果是某些目标类别在数据集中缺失模型永远学不到这个目标。5.3 中文/空格标签名labelme 的标签名理论上可以填任意字符串比如中文“汽车”、“道路”。YoloV8 的 class names 列表也支持字符串但实际操作中我不建议用中文。原因有两个一是某些系统环境对中文路径和编码的支持不稳定Windows 上尤其容易出乱码二是在dataset.yaml里写中文类别名时如果文件编码不一致训练时可能报字符编码错误。我的建议是标注阶段就用英文或拼音代替中文比如car、person、road。如果现有标注已经是中文了你可以在映射表里做一层转换把“汽车”映射到 1 号类别然后在dataset.yaml的 names 里写成英文。这样不影响训练也不会有编码问题。5.4 标注点顺序与目标自相交最后一个坑是关于多边形点顺序的。labelme 标注时你沿着目标边缘点的顺序绝大多数情况是顺时针或逆时针连续的。但在转换时我们必须意识到YoloV8 处理多边形时期望点的顺序是首尾相连的封闭路径如果点顺序错乱多边形会变成一个自相交的怪异形状训练出来的模型对目标边界的预测也会很奇怪。另外我还遇到过一种情况某个目标被标注成两个独立的多边形在 labelme 里分别命名比如一辆车被一个多边形挡住标注员把可见部分和不可见部分分别标成了两个car。转换后这两个多边形会在 txt 里各占一行属于两个独立的实例。如果数据集中这种情况多会干扰模型对“一个目标”的认知。建议标准流程是标注时保证多边形闭合、点顺序正确一个目标尽量只有一个多边形覆盖。实在有遮挡可以在训练时通过 NMS 后处理来合并检测结果。最后说点我的实际体会。这个转换脚本我前后迭代了三四版每次都是在真实项目里被数据“教育”之后才加上的各种判断逻辑。从最早的 40 行代码变成现在这种带分层抽样、带检查、带提示信息的版本其实每段逻辑背后都对应着一个真实发生过的坑。数据标注和转换这种“脏活累活”看着不起眼但它直接决定了模型训练的地基稳不稳。如果你也在做类似的数据准备工作建议多花点时间在格式转换和校验上这比之后调模型参数划算得多。本文还有配套的精品资源点击获取
返回列表