news 2026/8/31 15:47:12

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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:类别名,比如carroadperson
  • shapes[].points:多边形顶点列表,单位是像素坐标。注意,labelme 保存的是绝对像素坐标,也就是原图上真实的 x、y 值。
  • shapes[].shape_type:标注类型,最常见的是polygon,但也会有rectanglecirclelinepoint这些。做语义分割时只用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 开始的整数。比如你的数据集有backgroundcarroad三类,那背景就是 0,car 是 1,road 是 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,分布在训练和验证两个子目录里。训练时配置文件里指定trainval的图片路径即可,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库,文件操作用osshutil库,随机划分用random库,就连解码 Base64 图片数据也只需要base64标准库。

不过我还是建议你在本地新建一个干净的工作目录,里面放一个class_mapping.json文件,用来定义类别映射关系。为什么不直接在脚本里写死一个字典?因为实际项目里类别经常会调整,放在独立配置文件里,以后增删类别不用改代码,只改这个 JSON 就行,更加灵活。

2.2 转换前必须整理的类别映射表

类别映射是整个转换流程的“总闸门”,直接影响每行标签开头的那个数字 ID。我在做转换前的第一件事,就是打开 labelme 标注目录里的随便几个 JSON,把所有出现过的类别名拉出来排个序。

比如我检查后发现标注文件里出现了这些类别:backgroundcarpersonroadtree。那我的class_mapping.json就这样写:

{ "background": 0, "car": 1, "person": 2, "road": 3, "tree": 4 }

这里有两个建议:

  • background 尽量放在 0 号位。虽然 Pytorch 默认的交叉熵损失会把ignore_index设为 255,YoloV8 在语义分割任务里也有自己处理背景的方式,但把背景放在 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", encoding="utf-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_images=True): """转换单个 labelme JSON 文件为 YoloV8 分割标签,并复制对应图片。""" with open(json_path, "r", encoding="utf-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(f"JSON 文件 {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(parents=True, exist_ok=True) with open(out_txt_dir / txt_filename, "w", encoding="utf-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(parents=True, exist_ok=True) shutil.copy2(src_img, out_img_dir / image_filename) return True, skip_reasons def main(labelme_root, output_root, mapping_path, train_ratio=0.8, random_seed=42): # 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(parents=True, exist_ok=True) lbl_split_dir.mkdir(parents=True, exist_ok=True) 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_ratio=0.8, random_seed=42 )

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_seed=42这个参数不是随便写的,它的作用是保证每次运行脚本,划分结果完全一致。这样别人复现你的实验,或者你调了几天参数后想回退,都不会因为数据集划分不同而导致结果对不上。

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_ratio=0.8, random_seed=42): """按标签中的类别分布进行分层抽样划分。""" 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", encoding="utf-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(), key=lambda 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.yaml

path: /absolute/path/to/yolo_dataset train: images/train val: images/val names: 0: background 1: car 2: person 3: road 4: tree

然后用命令行直接开训:

yolo task=segment mode=train model=yolov8n-seg.pt data=dataset.yaml epochs=100 imgsz=640

到这一步,从标注数据到 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_path=None): """把 YoloV8 txt 标签可视化并叠加在原图上。""" img = cv2.imread(str(image_path)) img_h, img_w = img.shape[:2] with open(label_path, "r", encoding="utf-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], isClosed=True, color=color, thickness=2) 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", encoding="utf-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.jsonframe_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里写中文类别名时,如果文件编码不一致,训练时可能报字符编码错误。

我的建议是标注阶段就用英文或拼音代替中文,比如carpersonroad。如果现有标注已经是中文了,你可以在映射表里做一层转换,把“汽车”映射到 1 号类别,然后在dataset.yaml的 names 里写成英文。这样不影响训练,也不会有编码问题。

5.4 标注点顺序与目标自相交

最后一个坑是关于多边形点顺序的。labelme 标注时你沿着目标边缘点的顺序,绝大多数情况是顺时针或逆时针连续的。但在转换时我们必须意识到:YoloV8 处理多边形时,期望点的顺序是首尾相连的封闭路径,如果点顺序错乱,多边形会变成一个自相交的怪异形状,训练出来的模型对目标边界的预测也会很奇怪。

另外我还遇到过一种情况:某个目标被标注成两个独立的多边形,在 labelme 里分别命名,比如一辆车被一个多边形挡住,标注员把可见部分和不可见部分分别标成了两个car。转换后这两个多边形会在 txt 里各占一行,属于两个独立的实例。如果数据集中这种情况多,会干扰模型对“一个目标”的认知。

建议标准流程是:标注时保证多边形闭合、点顺序正确,一个目标尽量只有一个多边形覆盖。实在有遮挡,可以在训练时通过 NMS 后处理来合并检测结果。


最后说点我的实际体会。这个转换脚本我前后迭代了三四版,每次都是在真实项目里被数据“教育”之后才加上的各种判断逻辑。从最早的 40 行代码,变成现在这种带分层抽样、带检查、带提示信息的版本,其实每段逻辑背后都对应着一个真实发生过的坑。数据标注和转换这种“脏活累活”,看着不起眼,但它直接决定了模型训练的地基稳不稳。如果你也在做类似的数据准备工作,建议多花点时间在格式转换和校验上,这比之后调模型参数划算得多。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/31 15:45:39

Python+Tkinter五子棋开发实战:GUI编程与算法核心解析

简介&#xff1a;这是一份基于Python实现的五子棋小游戏完整项目资源&#xff0c;面向Python初学者与游戏开发入门者&#xff0c;帮助学习者掌握图形界面编程、事件响应、棋盘逻辑判断及音效/字体等多媒体资源集成方法。压缩包共19个文件&#xff0c;包含9个核心Python源码&…

作者头像 李华
网站建设 2026/8/31 15:42:38

AI+BI选型:如何搭建平衡功能成本风险的可落地评分模型

导语 要搭建可落地的AIBI选型评分模型&#xff0c;核心是围绕功能匹配度、全生命周期成本、实施与治理风险三个核心维度&#xff0c;通过需求前置梳理、PoC验证、量化打分的流程&#xff0c;跳出功能堆叠陷阱&#xff0c;形成适合自身业务的可量化选型判断依据。在本文讨论的阶…

作者头像 李华
网站建设 2026/8/31 15:39:05

C++17实现纯手写AES算法:从GF(2^8)到ECB/CBC分组模式

简介&#xff1a;本资源是一份面向C初学者与信息安全入门者的简易AES加解密算法实现项目&#xff0c;聚焦密码学核心原理与工程落地结合&#xff0c;解决学习者在理解对称加密机制、动手实现标准算法时缺乏可运行参考代码的痛点。压缩包共4个文件&#xff08;325KB&#xff09;…

作者头像 李华
网站建设 2026/8/31 15:38:59

51单片机锂电池检测仪与BMS设计:原理、算法到仿真实现

简介&#xff1a;本资源面向电子类专业学生、嵌入式初学者及电池管理系统&#xff08;BMS&#xff09;实践开发者&#xff0c;提供一套覆盖锂电池检测、电量估算、充放电保护与均衡管理的完整51/52单片机工程实践方案。资源包含4套相互关联又功能侧重不同的设计&#xff1a;电压…

作者头像 李华
网站建设 2026/8/31 15:38:56

自动化实战:使用Python+pyautogui自动登录B站,自动化操作就是如此简单!

鉴于上述所提及的问题, 在周末时光, 我撰写了一个借助加法运算“”来达成B站自动登录流程的内容。该流程主要涵盖了这些方面, 先是返回至桌面, 接着获取坐标, 随后启动浏览器, 再输入网址, 之后点击登录按钮, 然后输入账号密码, 最后进行登录操作。1&#xff09;坐标定位工具, …

作者头像 李华
网站建设 2026/8/31 15:38:50

Grok Build v1.0.12升级指南:先验证兼容性,再跑批量任务

Grok Build 更新到 v1.0.12 了。从 v1.0.7 上线&#xff0c;到 v1.0.9 发布&#xff0c;再到现在这个版本&#xff0c;迭代节奏不算慢。但版本号连续跳动&#xff0c;不代表每个新版本都值得立刻升级&#xff0c;更不代表所有人的使用方式都要跟着改。 如果你最近看到“Grok B…

作者头像 李华