
我见过太多人花了一周时间标完数据高高兴兴跑yolo train结果训练到一半 loss 变成 nan或者验证集的 mAP 只有个位数。查来查去问题基本都出在数据准备阶段——格式没对齐、坐标越界、训练集和验证集混进了同一批图像。这类问题有个共同特征报错信息模棱两可代码层面完全没毛病但结果就是不行。说实话YOLO 系列的训练代码已经成熟到一条命令跑通的地步了真正拉低效率的反而是训练之前那一堆不起眼的杂活COCO 的 JSON 和 VOC 的 XML 要怎么转成 YOLO 要的 txtLabelMe 标出来的多边形怎么算外接框数据集怎么划分才能保证验证集有意义这些事看起来琐碎但每件都藏着坑。这篇文章把我自己做数据整理的流程完整拉一遍包括 COCO/VOC/LabelMe 三种格式的底层逻辑、转 YOLO 格式的具体代码、训练前必须做的数据体检以及划分训练/验证集的正确姿势。不用你再去翻十篇博客拼答案这一篇基本够用。1. 三种标注格式的底层逻辑不理解结构转换就是瞎改很多人拿到代码就直接跑跑不通就开始问报错怎么办。实际上 COCO、VOC、LabelMe 转 YOLO 的代码网上多如牛毛真正让你翻车的不是代码本身而是不理解这三种格式各自的组织方式。一旦你搞清楚它们的底层逻辑转换脚本根本不需要背临时写都来得及。1.1 COCO 格式一棵 JSON 大树挂着一堆注释IDCOCO 数据集采用 JSON 文件描述整个数据集的语义结构最典型的是annotations目录下的instances_train2017.json这类文件。它的组织逻辑是通过 ID 互相引用images字段一个数组每个元素是一张图片的元信息包括id、file_name、width、height、license等。annotations字段一个数组每个元素是一个标注对象包含id、image_id引用了某张图片、category_id引用了某个类别、bbox格式是[x, y, width, height]、area、segmentation等。categories字段类别列表每个元素包含id、name、supercategory。这套设计的问题在于如果你想拿到某张图片的所有标注不能直接顺着读必须先把annotations全部扫一遍按照image_id做索引。说人话就是——COCO 的 JSON 是给程序用的不是给人肉眼核对的。数据量大时还牵涉到内存占用问题直接json.load()整个文件几个 G 的文件能把内存吃满。1.2 VOC 格式每个图配一个 XML读起来最友好PASCAL VOC 的输出方式则朴素很多每张图片对应一个同名的 XML 文件。XML 的主要结构是这样的annotation folderVOC2012/folder filename000001.jpg/filename size width500/width height375/height depth3/depth /size object namedog/name bndbox xmin100/xmin ymin50/ymin xmax300/xmax ymax200/ymax /bndbox /object /annotationVOC 的边界框直接给的是左上角和右下角的像素坐标xmin, ymin, xmax, ymax而且图像尺寸也放在同一个文件里。这意味着你拿到一个 XML不需要读图片就能完整还原出目标在图像中的位置也可以直接检查坐标是否越界。VOC 格式没有category_id这种间接层类别名直接写在name里对人不熟悉代码的人来说非常直观。问题在于VOC 目录结构比较复杂标准布局至少包含JPEGImages、Annotations、ImageSets/Main三个目录ImageSets里还要用 txt 文件列明训练集、验证集的图片名。很多人只转了标注忘了生成Main目录下的划分文件导致一些开源训练代码找不到数据。1.3 LabelMeJSON 里存多边形核心是形状而非框LabelMe 是标注工具不是数据集格式。点开labelme标注完一张图默认保存的是一份同名的.json文件里面记的是shapes数组——每个元素是一个形状多边形、矩形、圆等关键字段是points顶点坐标列表和label标签名同时还会记录imagePath、imageWidth、imageHeight等信息。很多人刚接触 LabelMe 时会问为什么它不直接存成框因为 LabelMe 的定位是通用标注工具既要能框物体也要能逐像素勾轮廓。你标注一个不规则物体时用它描点最后保存的一定是多边形坐标。但 YOLO 训练要的是边界框于是推导外接矩形成了邮件中绕不开的一步——bbox [min(x), min(y), max(x), max(y)]也就是取所有顶点的最小/最大值。LabelMe 的 JSON 还有一点容易踩坑如果标注时用的是矩形工具shape_type是rectanglepoints里只有两个点左上角和右下角但如果你用的是多边形工具即便你勾得像个矩形shape_type也依然是polygon。转换脚本必须兼容这两种情况。1.4 为什么 YOLO 非要自己的一套 txt 格式最后回到问题本身这些格式都挺清晰的为什么 YOLO 不直接用核心原因是训练效率。YOLO 在训练时需要频繁读取标注信息并做数据增强。如果每次都要解析一个几百 MB 的 JSON 或打开 XML 再找节点光 I/O 就够拖垮训练了。YOLO 采用极简的 txt 格式——每行一个目标格式是class_id center_x center_y width height所有坐标值全部归一化到 0~1 之间。归一化有两层好处第一输入图像尺寸变化时不需要重新标注第二模型输出天然就是 0~1 之间的偏移量归一化标注和模型预测在同一尺度下损失计算不需要额外换算。至于为什么是中心点而不是左上角这跟 YOLO 的设计思想有关——YOLO 把目标检测看作回归问题模型预测的是目标中心落在哪个网格里 中心相对网格的偏移 宽高相对整图的缩放所以训练标签也必须按这个格式给。理解了这一点你就不会写出x1 y1 x2 y2格式的错误转换代码了。2. 格式转换实战从 COCO/VOC/LabelMe 到 YOLO一次讲透理解了底层结构转换其实就是翻译。这里没有黑魔法关键就三件事坐标映射正确、归一化别除反、类别 ID 别错位。下面三段代码我都实测跑过可以直接抄。2.1 转换前必须明确的三个约定动手之前先确认你最终要产出的目录结构。YOLO 系列v5/v8/v11的数据加载通常要求这样的组织方式dataset/ ├── images/ │ ├── train/ │ └── val/ └── labels/ ├── train/ └── val/images/train/下放图片labels/train/下放同名.txt标注文件。注意图片是.jpg标注是.txt文件名必须完全一样不含扩展名。这是 YOLO 训练代码根据图片路径找标注文件的约定命名不一致会直接导致训练时WARNING: no labels found。另外三件事必须确定类别列表按什么顺序排列类别比如[dog, cat, person]还是[person, dog, cat]这会直接影响class_id而class_id是整数一旦定了就不能改。编号从 0 开始YOLO 的class_id一律从 0 开始不是从 1。COCO 的category_id有些从 1 开始转换时必须扣掉 1。归一化公式center_x (xmin xmax) / 2 / widthcenter_y (ymin ymax) / 2 / heightw (xmax - xmin) / widthh (ymax - ymin) / height。2.2 COCO 转 YOLO反着建索引注意类别 ID 错位COCO 转 YOLO 的核心是先把annotations按image_id建索引再遍历images逐一输出。直接看代码import json import os from collections import defaultdict def coco_to_yolo(coco_json_path, output_dir, category_mapNone): category_map: 可选。例如 {dog: 0, cat: 1}。如果为 None则按 COCO categories 顺序自动编号。 with open(coco_json_path, r, encodingutf-8) as f: coco json.load(f) # 建立 image_id - 图片信息 image_id_to_info {img[id]: img for img in coco[images]} # 类别映射 if category_map is None: category_map {} for idx, cat in enumerate(coco[categories]): category_map[cat[name]] idx # 构建 coco_category_id - yolo_class_id coco_cat_id_to_yolo_id { cat[id]: category_map[cat[name]] for cat in coco[categories] } # 建立 image_id - [annotations] anns_by_image defaultdict(list) for ann in coco[annotations]: anns_by_image[ann[image_id]].append(ann) # 写出 YOLO txt os.makedirs(output_dir, exist_okTrue) for image_id, img in image_id_to_info.items(): file_stem os.path.splitext(img[file_name])[0] width, height img[width], img[height] lines [] for ann in anns_by_image.get(image_id, []): cls_id coco_cat_id_to_yolo_id[ann[category_id]] bbox ann[bbox] # [x, y, w, h]COCO 格式 x, y, w, h bbox if w 0 or h 0: continue cx (x w / 2) / width cy (y h / 2) / height nw w / width nh h / height lines.append(f{cls_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}) with open(os.path.join(output_dir, file_stem .txt), w, encodingutf-8) as f: f.write(\n.join(lines)) if __name__ __main__: coco_to_yolo(instances_train2017.json, labels_train)这段代码里最容易翻车的是category_map。如果你自己的数据集类别和 COCO 的 80 类不一样一定要自己传category_map。否则代码按coco[categories]顺序自动编号跟你的预期几乎必然不一致。2.3 VOC 转 YOLO解析 XML 的 bndbox注意文件名大小写VOC 和 COCO 的转换逻辑不同COCO 是单文件遍历VOC 是一张图一个 XML。用 Python 的标准库xml.etree.ElementTree就能解析不需要额外依赖import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, output_dir, category_map): tree ET.parse(xml_path) root tree.getroot() size root.find(size) width int(size.find(width).text) height int(size.find(height).text) file_stem os.path.splitext(root.find(filename).text)[0] if root.find(filename) is not None else os.path.splitext(os.path.basename(xml_path))[0] lines [] for obj in root.findall(object): name obj.find(name).text if name not in category_map: continue # 不在目标类别列表里跳过 cls_id category_map[name] bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) # 坐标裁剪到 [0, w/h] 以内防止浮点误差 xmin max(0, min(xmin, width)) ymin max(0, min(ymin, height)) xmax max(0, min(xmax, width)) ymax max(0, min(ymax, height)) if xmax xmin or ymax ymin: continue cx (xmin xmax) / 2 / width cy (ymin ymax) / 2 / height nw (xmax - xmin) / width nh (ymax - ymin) / height lines.append(f{cls_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}) os.makedirs(output_dir, exist_okTrue) with open(os.path.join(output_dir, file_stem .txt), w, encodingutf-8) as f: f.write(\n.join(lines)) def convert_voc_folder(annotations_dir, categories_txt, output_dir): category_map {} with open(categories_txt, r, encodingutf-8) as f: for idx, line in enumerate(f): category_map[line.strip()] idx for xml_file in os.listdir(annotations_dir): if not xml_file.endswith(.xml): continue voc_to_yolo(os.path.join(annotations_dir, xml_file), output_dir, category_map) if __name__ __main__: convert_voc_folder(Annotations, classes.txt, labels_train)VOC 转 YOLO 时坐标裁剪这步经常被忽略。看起来xmin、xmax是标注工具算好的实际上很多数据集的标注框会超出图像边界几个像素尤其是用半自动标注工具生成的数据。裁剪掉之后才能防止训练时 anchor 匹配出问题。2.4 LabelMe 转 YOLO多边形算外接框rectangle 和 polygon 都要处理LabelMe 的 JSON 和 COCO 一样用json.load()读取即可。每个标注对象在shapes数组里需要区分两种常见的shape_typerectanglepoints只有两个点[[x1, y1], [x2, y2]]这俩就是左上角和右下角。polygonpoints有多个点需要取x最小值、y最小值、x最大值、y最大值。另外有个小细节LabelMe 的 JSON 里虽然存了imageWidth和imageHeight但如果你在标注之后对图片做过裁剪、缩放这俩尺寸可能已经不对了。保险做法是转换时用cv2.imread或PIL.Image.open读取图片实际尺寸而不是盲信 JSON 里的字段。代码import json import os import numpy as np from PIL import Image def labelme_to_yolo(json_path, output_dir, category_map, image_rootNone): with open(json_path, r, encodingutf-8) as f: data json.load(f) # 优先用 JSON 里的尺寸但如果提供了 image_root 则读取真实尺寸 if image_root: img_path os.path.join(image_root, data[imagePath]) with Image.open(img_path) as img: width, height img.size else: width data[imageWidth] height data[imageHeight] file_stem os.path.splitext(data[imagePath])[0] lines [] for shape in data[shapes]: label shape[label] if label not in category_map: continue cls_id category_map[label] points np.array(shape[points], dtypenp.float32) if shape[shape_type] rectangle: x1, y1 points[0] x2, y2 points[1] else: # polygon x1, y1 points.min(axis0) x2, y2 points.max(axis0) # 同样裁剪到图像范围内 x1 max(0, min(x1, width)) y1 max(0, min(y1, height)) x2 max(0, min(x2, width)) y2 max(0, min(y2, height)) if x2 x1 or y2 y1: continue cx (x1 x2) / 2 / width cy (y1 y2) / 2 / height nw (x2 - x1) / width nh (y2 - y1) / height lines.append(f{cls_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}) os.makedirs(output_dir, exist_okTrue) with open(os.path.join(output_dir, file_stem .txt), w, encodingutf-8) as f: f.write(\n.join(lines)) def convert_labelme_folder(json_dir, categories_txt, output_dir, image_rootNone): category_map {} with open(categories_txt, r, encodingutf-8) as f: for idx, line in enumerate(f): category_map[line.strip()] idx for json_file in os.listdir(json_dir): if not json_file.endswith(.json): continue labelme_to_yolo(os.path.join(json_dir, json_file), output_dir, category_map, image_root)LabelMe 转 YOLO 还有一个很隐蔽的问题polygon类型取外接框之后如果多边形是旋转的矩形斜着框出来的目标外接框会比实际目标大不少里面会混入很多背景像素。这种情况下要不要改用rectangle重新标注取决于你的目标形态。如果是车辆、船舶这类长条形目标旋转外接框的损失会很明显我建议标注时尽量用rectangle或者拆分多段框。3. 训练前的数据体检图片、标注、类别一道都不能少好不容易转换完了千万别直接开训。数据里隐藏的问题远比你想象的多——损坏图片、越界框、空标签、类别数量不平衡每一项都能让训练结果变得不可预测。我把训练前要做的检查整理成一套体检流程按顺序跑一遍基本能排除 80% 以上的数据问题。3.1 图片体检解码失败、损坏文件、颜色模式异常最容易被忽略的是图片本身的问题。从网上下载的数据集、别人拷贝的数据很可能包含损坏图片或者非标准格式。cv2.imread()读不出来时会返回None但有些图片用cv2读没问题用PIL打开就报错——两个库对损坏文件的容错机制不一样。我的做法是跑一遍完整扫描import os from PIL import Image import cv2 def check_images(image_dir): bad_files [] for root, _, files in os.walk(image_dir): for f in files: if not f.lower().endswith((.jpg, .jpeg, .png, .bmp, .webp)): continue path os.path.join(root, f) # 用 PIL 检查是否完整 try: with Image.open(path) as img: img.verify() except Exception as e: bad_files.append((path, fPIL: {e})) continue # 用 OpenCV 检查是否能解码 img cv2.imread(path) if img is None: bad_files.append((path, OpenCV decode failed)) return bad_files这个脚本跑完把坏文件单独挪到一个文件夹再决定是重新下载还是删除。如果坏文件很少比如不到 1%我建议直接删除对应图片和标注不要留着让训练时报错。还有一类图片问题不报错但影响结果EXIF 方向信息。手机拍摄的照片会写入Orientation字段标注工具标注时是在旋转后的图像上标的但训练时 OpenCV 读取图像不自动应用 EXIF导致标注和图像内容差了 90°。建议在整个流程的一开始就用ImageOps.exif_transpose把图片转正再保存一次。3.2 标注体检越界、空框、重复框、宽高为 0这一项我用一个独立脚本扫描转换后的 YOLO 标签文件。规则很简单每一行必须解析出 5 个数值class_id必须在[0, num_classes)范围内center_x、center_y、width、height都应该在(0, 1]区间内width、height不能太接近 0小于 0.001 的基本可以认为是空标注import os def check_yolo_labels(label_dir, num_classes): issues [] for root, _, files in os.walk(label_dir): for f in files: if not f.endswith(.txt): continue path os.path.join(root, f) with open(path, r, encodingutf-8) as fh: lines fh.readlines() if len(lines) 0: issues.append((path, empty label file)) continue for i, line in enumerate(lines): parts line.strip().split() if len(parts) ! 5: issues.append((path, fline {i}: {len(parts)} values)) continue cls, cx, cy, w, h parts cls int(cls) cx, cy, w, h float(cx), float(cy), float(w), float(h) if cls 0 or cls num_classes: issues.append((path, fline {i}: class {cls} out of range)) if not (0 cx 1) or not (0 cy 1): issues.append((path, fline {i}: center out of [0,1])) if w 0 or h 0 or w 1 or h 1: issues.append((path, fline {i}: wh invalid)) return issues这里有个容易出错的点(0, 1]的判断。center_x为 0 或 1 时都有问题——等于 0 意味着目标中心在图像最左边缘等于 1 在图像右边缘之外。虽然边界值出现的概率低但一旦出现模型训练时边界框回归会造成梯度异常。3.3 类别体检类别列表一致性、类别平衡性类别问题分两种一种是标签里出现了类别列表之外的 ID上面代码已经覆盖另一种是类别分布严重不均——某个类别有几千个样本另一个类别只有几十个。类别不平衡在目标检测里很常见特别是不论是自建数据集还是从开源数据集裁剪出来的子集都可能存在长尾分布。训练时如果某个类别样本太少模型对该类别的学习几乎无效推理时该类别的 AP 会惨不忍睹。我的建议是训练前统计每个类别的目标数量不是图片数量是标注框数量画一个柱状图。如果某类目标少于 100 个考虑这几个策略数据增强Mosaic、Copy-Paste、旋转、缩放增加样本多样性对该类别做重采样训练时权重上调如果条件允许补充标注更多该类别数据而且类别名不要用中文。YOLO 训练代码很多对中文路径和中文标签支持都不好尤其在 Windows 环境下。全部转成英文小写dog-0cat-1这也能避免后续部署时编码问题。4. 数据划分训练/验证/测试的科学配比与随机种子问题数据划分看起来最简单——shutil.move两个文件不就行了但里面坑不少最核心的是训练的验证集必须和测试集分布一致否则调参时你根本不知道模型到底过拟合了没有。4.1 为什么不能手动拖文件夹有的人建两个文件夹凭感觉把图片拖进去。这种做法的最大问题是不可复现——你这次跑的效果不错但你不知道是模型好还是数据划分恰好运气好。下次加数据重新划一遍结果完全变了模型之间的对比就失去了公平性。正确做法是写一个划分脚本设置随机种子然后把划分结果保存成文件。这样任何时候想复现实验只需要把train.txt、val.txt里的图片路径拿出来就行。4.2 基础的按文件名随机划分最简单的实现是直接用random.shuffle加训练集 80% / 验证集 20%代码长这样import os import random from sklearn.model_selection import train_test_split def split_dataset(image_dir, label_dir, train_ratio0.8, seed42): all_stems set() for f in os.listdir(image_dir): if f.lower().endswith((.jpg, .jpeg, .png)): all_stems.add(os.path.splitext(f)[0]) # 过滤确保图片和标签都存在 valid_stems [] for stem in all_stems: img_path os.path.join(image_dir, stem .jpg) # 按实际扩展名处理 label_path os.path.join(label_dir, stem .txt) if os.path.exists(label_path): valid_stems.append(stem) train_stems, val_stems train_test_split( valid_stems, train_sizetrain_ratio, random_stateseed ) with open(train.txt, w) as f: for stem in train_stems: f.write(os.path.join(image_dir, stem .jpg) \n) with open(val.txt, w) as f: for stem in val_stems: f.write(os.path.join(image_dir, stem .jpg) \n)注意这里用的是sklearn.model_selection.train_test_split它内部做了random shuffle只要固定random_state每次跑出来的划分都是一样的。4.3 按类别分层的划分防止极端情况普通随机划分在样本量上千时问题不大但如果某些类别特别少随机划分可能导致某个类别的所有目标都进了训练集验证集里一个都没有。这会导致验证时该类别的 AP 因为分母为 0 而无法计算——或者根本不会触发该类别相关的评估逻辑。更稳妥的做法是按图片级别的类别分布做分层采样。我先给每张图片打个类别标签向量包含哪些类别然后按照该向量做分层分组。简单实现思路from collections import defaultdict def stratified_split_by_classes(label_dir, image_stems, test_ratio0.2, seed42): # 统计每个图片包含的类别 stem_to_classes {} for stem in image_stems: label_path os.path.join(label_dir, stem .txt) with open(label_path) as f: classes set() for line in f: parts line.strip().split() if parts: classes.add(int(parts[0])) stem_to_classes[stem] classes # 按第一个类别分组再在组内按比例抽取测试集 groups defaultdict(list) for stem, classes in stem_to_classes.items(): # 选类别中最小的那个用于分组保证每组有代表性 groups[min(classes)].append(stem) rng random.Random(seed) val_stems [] train_stems [] for cls, stems in groups.items(): rng.shuffle(stems) split_idx int(len(stems) * (1 - test_ratio)) train_stems.extend(stems[:split_idx]) val_stems.extend(stems[split_idx:]) return train_stems, val_stems这段代码按图片包含的最小类别 ID分组本质上是保证每个类别都有训练和验证样本。更严格的方案是迭代优化分组让每个类别的样本比例尽量一致但对大多数项目来说上层的近似方案已经够用。4.4 每组数据按组划分防止数据泄漏最后一个场景必须单独说如果数据来自同一段视频的连续帧或者同一场景下拍摄的多张图片直接随机划分会造成严重的数据泄漏——因为相邻帧实在太像了模型记住的是具体帧而不是目标类别。验证集和训练集高度相似验证分数虚高真正部署到新场景就露馅。这种情况要把图片按场景或视频片段聚合然后按组划分。比如一组视频抽帧得到的图片如果在文件名里有scene_01_0001.jpg这种前缀就应该把scene_01下的所有帧作为一个整体放进训练集或者验证集不能拆开。实现方式很简单用groupby逻辑按前缀分组再把组随机划分。这个坑是最难发现的因为训练过程完全正常loss 曲线也漂亮但实际部署效果奇差。如果你发现模型在训练集上效果好但验证集上分数莫名很高、实际部署却拉胯先怀疑这个问题。5. 我踩过的坑那些文档不会告诉你的数据细节数据整理想做到位除了上面的流程还有一堆零碎的经验。这些细节单看都不起眼但每个都曾经让我多花过一整天调试。5.1 类别 ID 从 0 开始和很多工具默认从 1 开始的习惯冲突LabelImg一个 VOC 标注工具保存的 XML 里类别名是字符串转 YOLO 时按classes.txt顺序编号从 0 开始。但 Roboflow 导出 YOLO 格式时标签文件里有classes.txt且编号从 0 开始。看起来一致但如果你用过某些老版本的工具或者从 COCO 转过来时忘了category_id - 1就会出现类别偏移 1的错位问题。排查技巧训练前抽出几张图把 YOLO txt 还原画到图上。如果框的位置不对说明坐标转换有问题如果框的位置完全对但标签错了一个类基本就是 ID 偏移。画框验证是我强烈推荐的一步。5.2 宽高在归一化后除以图片宽高还是除以最大边答案永远是以实际宽高为准YOLO 内部做推理时会把图片等比缩放到指定尺寸比如 640×640但训练标签的归一化基准必须是原始图片尺寸。如果你转换时用了缩放后的尺寸比如 640那标签就会出现系统性偏差模型根本学不到准确的目标位置。还有一个常见错误是把width和height分别用于除以 x 和 y但实际上center_x除以的是图像宽度center_y除以的是图像高度。很多新手会把两个都除以图像宽、或者交叉除错导致框的位置全部出错但形状正常——这种错误极难发现因为你肉眼看不出 0.01 的偏移意味着什么。5.3 文件路径和文件名通配符批量转换时最容易出错的环节批量转换 COCO 或 VOC 时最容易踩的坑是扩展名。比如 VOCO 的filename标签是000001.jpg但实际文件可能是000001.JPGWindows 大小写不敏感Linux 大小写敏感。如果你在 Linux 服务器上训练os.path.exists返回 False标注文件就不会生成。我的处理方式是读 XML 时不直接信任filename而是到JPEGImages目录里按id匹配实际存在的文件名把真正的文件名作为基准。同理LabelMe 的imagePath有时只写文件名有时写的是相对路径转换前也要规范化。5.4 用可视化做最终验收别嫌麻烦所有代码跑完之后强烈建议随机抽 20~30 张图把 YOLO 标注画回图片上肉眼确认一遍。代码很简单import cv2 def draw_yolo_labels(img_path, label_path): img cv2.imread(img_path) h, w img.shape[:2] with open(label_path) as f: for line in f: parts line.strip().split() if not parts: continue cls, cx, cy, bw, bh map(float, parts) cx, cy, bw, bh cx * w, cy * h, bw * w, bh * h x1 int(cx - bw / 2) y1 int(cy - bh / 2) x2 int(cx bw / 2) y2 int(cy bh / 2) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, str(int(cls)), (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) return img画完看一眼框是否紧贴目标、类别编号是否符合预期、有没有框明显大一圈或飘到背景上。这步能过滤掉坐标转换和类别映射的大多数问题比直接训练等结果快得多。5.5 保留原始数据别用转换后的文件覆盖原标注最后一条血的教训处理数据一定要保留原始标注格式。因为换模型、换工具、换任务时你往往需要重新做转换。如果一开始就把 COCO JSON 转成 YOLO txt 并删掉了原始文件回头想改类别列表、重新划分、或者试试检测分割多任务就只能重标——那种绝望经历过一次就再也不想经历。我的习惯是维护一个raw_dataset/目录里面存原始图片和原始标注JSON/XML然后任何转换脚本的输出都放到processed_yolo_v8/或其他带版本号的目录里。每次转换把项目里用的classes.txt路径记录下来方便追溯。数据整理这件事说起来不复杂但每一步都藏着小坑。我自己从最早被 COCO 的 ID 折磨、到 Video 抽帧踩数据泄漏的坑再到后来养成了检查可视化保留原始数据的习惯前后也折腾了大半年。把这套流程固定下来之后新增数据集的准备工作从两三天压缩到半天以内而且训练出问题的概率大幅下降。下次拿到新数据别急着开训先按这篇文章的流程走一遍你会回来感谢自己的。