做检测项目这几年,我踩过最大的坑往往不在模型,而在数据。前阵子帮朋友处理一套电力红外检测数据,也就是大家常说的firc-dataset那种场景——几千张红外图,标注完是标准的VOC格式,转成YOLO格式送进去训练,结果loss死活不降。排查了一个下午,最后发现只是坐标归一化时把宽和高的顺序搞反了。这类“标注两星期,转换两小时,训练崩两天”的循环,我相信做过目标检测的同行多多少少都经历过。这篇就把检测数据集制作的全流程一次讲清楚:数据怎么收集、标注怎么做、VOC/COCO/YOLO格式之间到底差在哪、互转怎么一次搞对,以及转完之后的验证流程。不管是做鸟类目标检测、车辆检测(BDD100K那种)、开关闭合检测还是传送带异物检测,这套流程都是通用的。
1. 数据收集:先别急着标注,想清楚这三件事再说
1.1 数据来源:公开数据集、现场采集和“半成品”数据的取舍
数据集制作的第一步不是打开标注工具,而是解决“图从哪来”的问题。我见过太多人上来就手动标注,结果标了三分之一发现类别定义不清晰,全部返工。根据我自己的实操经验,数据来源一般分三条路:
- 公开数据集:像VOC2007/2012、COCO、BDD100K、VisDrone这类,优点是有现成的标签、类别体系成熟、可以直接用于预训练或者迁移学习。缺点是领域不一定匹配——你做电力设备检测,总不能指着COCO里的猫狗用。所以公开数据集更适合作为“基础数据”或者“负样本”,用来丰富背景多样性,防误检。
- 现场/真实场景采集:这是工业项目里最可靠的数据来源。摄像头架在产线传送带上方、无人机飞一圈变电站、手机拍配电柜,都是常见方式。采集的时候注意光照变化、角度变化、远近尺度变化,尽量让目标出现的位置和大小在画面里覆盖足够多的可能性。另外,不同时间段、不同天气、不同设备型号都要覆盖到,否则模型很容易过拟合到某一种特定环境。
- “半成品”数据的再处理:比如已有的视频监控录像抽帧、网上爬来的设备图、客户提供的现场照片。这类数据最大的问题是质量参差不齐,标注前必须做一轮清洗。
这里有个容易被忽略的点:数据量不是越多越好,均衡度才是关键。我曾经做过一个开关闭合检测的项目,闭合状态的图片有8000张,断开状态只有600张,训练出来的模型对“断开”的召回率惨不忍睹。所以收集阶段就要有意识地做类别分布统计,发现某个类别明显偏少,优先补充那个类别的数据。
1.2 清洗比你想的重要:哪些图必须扔掉
收集回来的数据,不等于是能用的数据。我在项目里会固定做一轮清洗,规则很简单:
- 严重模糊、过暗或过曝的图直接删。标注的时候勉强能看清边缘,但训练的时候模型学到的是噪声。
- 目标占比过小的图要谨慎。比如一张1920x1080的画面里,目标只有10x10像素,这种样本对检测器的训练意义不大,还容易干扰损失函数。
- 重复度太高的图要去重。视频抽帧出来的数据往往前后几帧几乎一样,如果不做去重,训练集和验证集之间就会有“近亲”数据,导致验证指标虚高。
- 标注难度极大的图(目标被严重遮挡、多个目标密集交叠)单独放。这类图不是不能用,而是应该放在训练集后半段,等模型先学会简单样本再上难度。
我习惯在采集后写一个简单的统计脚本,把所有图片尺寸、通道数、格式列出来,检查是否有损坏图片或尺寸异常。很多转换脚本报错,根源就是数据里混了一张损坏的JPG,你花了一晚上debug,最后发现是图片的问题。
1.3 类别体系先设计,再动手标注
类别体系是数据集的地基,这块我吃过亏。有一回做输电线路异物检测,一开始只定义了“异物”这一个类别,标了2000张后发现——“异物”和“鸟巢”“塑料袋”在模型眼里完全是不同类型的东西,统一叫“异物”导致模型学得稀里糊涂。后来重新定义成“鸟巢”“悬挂异物”“飘挂物”三个类别,效果立竿见影。
设计类别体系时有几个原则:
- 类别名用英文,不要用中文。倒不是崇洋媚外,而是很多框架对中文标签支持不好,VOC转YOLO的时候类别映射文件(classes.txt)里如果写入中文,轻则告警,重则训练直接崩溃。
- 类别之间要互斥。同一个目标不能同时属于两个类别。比如“开关”和“闭合的开关”就不能同时作为两个类别存在,正确的做法是定义“开关”类别,再用状态细分,或者用“开关闭合”“开关断开”两个互斥类别。
- 类别粒度要服务于业务需求。做检测不是类别越多越好。比如车辆检测,如果业务只需要区分“车”和“人”,就不要硬分成“轿车”“卡车”“公交车”,类别越多,标注成本越高,模型难度越大,互相混淆的概率也越高。
2. 标注环节:工具选型和规范制定决定模型上限
2.1 标注工具怎么选:从LabelImg到半自动标注
标注工具的选择,直接影响效率和输出格式的兼容性。我个人的使用经验是这样的:
| 工具 | 适合场景 | 输出格式 | 优缺点 |
|---|---|---|---|
| LabelImg | 小型项目、个人学习 | VOC XML / YOLO TXT | 轻量、入门简单,但批量处理能力弱 |
| Labelme | 多边形/实例分割 | JSON | 做分割项目好使,矩形检测就不必用它了 |
| X-AnyLabeling | 中等规模项目 | VOC / COCO / YOLO | 自带AI预标注,效率高,值得推荐 |
| CVAT | 团队协作、工业级 | VOC / COCO / YOLO / TFRecord | 功能最强,但部署和维护成本高 |
个人建议:如果你只是几百张图的实验,LabelImg够了;如果是几千张甚至上万张的工程级数据集,直接用X-AnyLabeling或者CVAT,一定要选带“AI预标注”能力的工具。比如YOLOv8框一下,人工去微调,总比从零画框快好几倍。一个5000张的鸟类检测数据集,纯手工标注大概要两周,用预标注辅助,一周就能搞定,而且人工只需要关注边界修正。
2.2 标注规范:边框紧贴目标,遮挡和边缘情况有规则
很多初学者标注随意,框大一点“差不多就行”,这是数据集质量的大忌。检测模型学的是“框里的特征”,如果框普遍偏大,模型输出的框也会偏大;框时紧时松,模型学到的边界就是模糊的。我这边会定几个死规矩:
- 边框紧贴目标可见部分。矩形框必须贴合目标的最小外接矩形,目标不可见的部分不纳入。比如人被车挡住一半,框就框可见的那一半,不要试图“脑补”完整人体。
- 遮挡超过70%的目标可以不标。模型学不到被遮挡目标的完整特征,强行标注反而会给训练带来噪声。
- 边缘目标要标。目标只有一小部分在画面内,依然要正常标注,这对模型在真实场景中的鲁棒性很重要。
- 清晰界定“困难样本”。VOC格式里有
difficult标记,这类样本如果标注了,建议在转换时单独处理——有些转换脚本不区分difficult,直接全部转成训练样本,结果模型被迫去学那些极难分辨的案例,精度反而下降。
这里我得强调一句:标注不是“画框”那么简单,一致性比准确度更重要。同一个目标,不同的人可能画出的框大小不同;同一个人,上午和下午的手感也可能不同。多人协作时务必统一规范,否则你得到的就是一个“每个人都有自己的标法”的数据集。
2.3 标注完成的质检:抽检30%,重点看边界和漏标
标注完成不等于可以直接训练。我每次在标注完成后,都会用脚本做一轮自动检查和一轮人工抽检。
自动检查主要做这几件事:
- 框坐标是否越界(xmin < 0、xmax > 图片宽度等)
- 框面积是否为0(两个顶点坐标相同)
- 类别是否在预设列表里
- 是否有多边形/矩形格式错乱
人工抽检我通常会抽30%左右的图,把标注框画回图上,快速翻一遍,重点看三类问题:边界贴合度、目标漏标、类别标错。
这些检查和抽检环节,看起来多花了两三天,实际上省的可能是后面调模型的一个星期。数据集质量不行,你怎么调损失函数、怎么换骨干网络,都是事倍功半。
3. VOC、COCO、YOLO三种格式的底层差异:互转不出错的关键
3.1 VOC XML:基于文件夹的层级结构,适合中小数据集
Pascal VOC格式本质上就是每个图片配一个同名XML文件,里面记录图片尺寸、目标类别和目标的bounding box坐标。它的核心结构是:
- 顶层是
annotation - 里面有
folder、filename、source、size(width/height/depth) - 每个
object里有name(类别字符串)、bndbox(xmin/ymin/xmax/ymax)
VOC的坐标是绝对像素坐标,左上角为原点,x向右增大,y向下增大。这点和OpenCV、PIL的坐标系统一致,处理起来很直观。
VOC格式的好处是**“一眼看懂”**,XML是文本文件,直接打开就能检查。缺点是冗余信息多,2000张图就是2000个XML文件,管理起来不如单文件方便。此外它的类别是字符串,模型训练时需要映射成整数索引,这个映射如果每次转换都重新定义,就会出大问题——后面我会专门讲。
3.2 COCO JSON:单文件集中式管理,信息更丰富
COCO格式是我个人觉得“结构最优雅”的格式。所有信息放在一个JSON文件里,顶层有info、licenses、images、annotations、categories五块。
三个关键数组:
categories:类别列表,id是整数,name是字符串images:每张图的id、文件名、宽高annotations:每个标注的id、关联的image_id、category_id、bbox、area和iscrowd
这里要注意,COCO的bbox是[x, y, width, height],其中x、y是左上角坐标,width和height是框的宽度和高度,单位同样是像素。很多人在VOC转COCO时习惯性地把坐标写成VOC的四点形式,或者把宽高写成了右下角坐标,这是高频错误。
3.3 YOLO TXT:每张图一个标签文件,坐标全部归一化
YOLO格式是目前实践中最流行的格式,尤其YOLOv5/v8系列以及各种改进结构(efficient head yolo、mamba yolo之类)的仓库默认都吃这个格式。
它的规则很简单:每一张图片对应一个同名的.txt文件,文件名前缀和图片完全一致,只是扩展名不同。文件里每一行代表一个目标:
class_id center_x center_y width height注意三点:
class_id是从0开始的整数,这个顺序取决于你转换脚本里定义类别列表的顺序,而不是XML里的写序。- 坐标是相对于图片宽高的归一化值,范围在0到1之间,所以一个简单目标是100x100的框,在1920x1080的图上,它的w就是100/1920≈0.0521,h是100/1080≈0.0926。
- 文本文档里没有图片尺寸信息,训练时靠的是图片文件本身去获取宽高,再做反归一化得到像素框。
YOLO格式的优点是可以直接用文本编辑器打开检查、占用空间小。缺点是如果图片尺寸变了(比如训练时resize),这些归一化坐标依然有效,这其实是它比像素坐标更稳的地方。
3.4 三种格式差异对照:一张表说清楚
| 维度 | VOC XML | COCO JSON | YOLO TXT |
|---|---|---|---|
| 存储方式 | 每图一个XML | 单文件JSON | 每图一个TXT |
| 坐标类型 | 绝对像素坐标 | 绝对像素坐标 | 归一化相对坐标 |
| 框的表示 | xmin,ymin,xmax,ymax | x,y,width,height | center_x,center_y,width,height |
| 类别 | 字符串(object name) | 整数id(categories定义) | 整数id(classes.txt顺序) |
| 附带信息 | 尺寸、遮挡、困难标记 | 面积、iscrowd等 | 仅框和类别 |
| 人类可读性 | 好 | 一般 | 好 |
这张表是格式互转的全部底层依据。你只要搞清楚坐标系统(像素 vs 归一化)、框的表示方式(两点 vs 左上+宽高 vs 中心+宽高)、类别表示(字符串 vs 整数id)这三个差异,转换脚本自己都能写出来。
4. 格式互转的实战脚本:直接复制改改就能用
4.1 VOC转YOLO:最常用的转换,注意归一化和类别映射
VOC转YOLO在检测项目里出现的频率最高,我直接给一个可复用的Python脚本,核心逻辑用标准库加上xml.etree就能跑完,不依赖第三方库。
import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_dir, output_dir, classes_file): # 读类别列表,注意顺序就是YOLO的class_id with open(classes_file, 'r', encoding='utf-8') as f: classes = [line.strip() for line in f.readlines()] class_to_id = {name: idx for idx, name in enumerate(classes)} os.makedirs(output_dir, exist_ok=True) for xml_file in os.listdir(xml_dir): if not xml_file.endswith('.xml'): continue tree = ET.parse(os.path.join(xml_dir, xml_file)) root = tree.getroot() # 读取图片尺寸 size = root.find('size') img_w = int(size.find('width').text) img_h = int(size.find('height').text) # 文件名前缀:保持和图片一致 base_name = os.path.splitext(xml_file)[0] out_file = os.path.join(output_dir, base_name + '.txt') with open(out_file, 'w', encoding='utf-8') as f: for obj in root.iter('object'): # 跳过difficult样本(按需保留) difficult = obj.find('difficult') if difficult is not None and int(difficult.text) == 1: continue name = obj.find('name').text if name not in class_to_id: print(f'警告: {xml_file} 存在未知类别 {name}') continue cls_id = class_to_id[name] bbox = obj.find('bndbox') xmin = float(bbox.find('xmin').text) ymin = float(bbox.find('ymin').text) xmax = float(bbox.find('xmax').text) ymax = float(bbox.find('ymax').text) # 绝对像素转中心点归一化 cx = ((xmin + xmax) / 2) / img_w cy = ((ymin + ymax) / 2) / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h # 钳制到0-1,防止越界 cx = min(max(cx, 0.0), 1.0) cy = min(max(cy, 0.0), 1.0) w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) f.write(f'{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}\n') print(f'转换完成,输出到 {output_dir}') # 使用示例 # voc_to_yolo('VOC2007/Annotations', 'yolo_labels', 'classes.txt')这个脚本有几个细节我在实战中反复踩过:
classes.txt的顺序就是class_id的顺序。如果你的类别列表是手动敲的,千万要和标注时的类别定义完全一致,少一行多一行都会导致后续训练时类别错位。我最惨的一次教训就是把classes.txt里一个类别的顺序写错了,训练出来的模型把“鸟”识别成“车辆”,因为class_id对不上。- 使用./码时注意“中心点”才是YOLO需要的。从VOC的xmin/ymin/xmax/ymax先算中心点再归一化,一步都不能省。有人图省事,直接把xmin除以宽、宽度除以宽,结果框全偏到左上角。
- 钳制到0~1很重要,标注时偶尔会遇到超出边界的框,不钳制的话后续训练很容易出NaN。
4.2 COCO转YOLO:重点处理“类别ID映射”和“图片宽高”
COCO转YOLO比VOC转YOLO稍微绕一点,因为COCO的categories里的id是自定义的,可能是100、120这种非连续值,必须重新映射。
import json import os def coco_to_yolo(coco_json, output_dir): with open(coco_json, 'r', encoding='utf-8') as f: data = json.load(f) os.makedirs(output_dir, exist_ok=True) # 构建COCO类别id -> YOLO class_id的映射 cat_id_to_cls_id = {} for idx, cat in enumerate(sorted(data['categories'], key=lambda x: x['id'])): cat_id_to_cls_id[cat['id']] = idx # 图片id -> 宽高 和 文件名 img_id_to_info = {} for img in data['images']: img_id_to_info[img['id']] = { 'file_name': img['file_name'], 'width': img['width'], 'height': img['height'] } # 按image_id聚合annotations img_anns = {} for ann in data['annotations']: img_id = ann['image_id'] img_anns.setdefault(img_id, []).append(ann) for img_id, anns in img_anns.items(): img_info = img_id_to_info[img_id] base_name = os.path.splitext(img_info['file_name'])[0] out_file = os.path.join(output_dir, base_name + '.txt') w = img_info['width'] h = img_info['height'] with open(out_file, 'w', encoding='utf-8') as f: for ann in anns: cls_id = cat_id_to_cls_id[ann['category_id']] x, y, bw, bh = ann['bbox'][:4] cx = (x + bw / 2) / w cy = (y + bh / 2) / h nw = bw / w nh = bh / h f.write(f'{cls_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}\n') # coco_to_yolo('annotations.json', 'yolo_labels')注意COCO的bbox里的宽高是绝对值,直接对应到YOLO的宽高,不需要再从两点坐标去推导。
4.3 YOLO转COCO:反归一化最容易被忽略的“图片实际尺寸”
反向转换,YOLO转COCO,需要读图片才能拿到宽高,因为YOLO的txt里没有尺寸信息。
import json import os from PIL import Image def yolo_to_coco(img_dir, label_dir, classes_file, output_json): with open(classes_file, 'r', encoding='utf-8') as f: classes = [line.strip() for line in f.readlines()] images = [] annotations = [] categories = [] ann_id = 1 for i, cls in enumerate(classes): categories.append({ 'id': i, 'name': cls, 'supercategory': 'none' }) for img_name in sorted(os.listdir(img_dir)): if not img_name.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp')): continue with Image.open(os.path.join(img_dir, img_name)) as img: w, h = img.size image_id = len(images) + 1 # 处理EXIF旋转问题(后面会细讲) from PIL import ImageOps img2 = Image.open(os.path.join(img_dir, img_name)) fixed_img = ImageOps.exif_transpose(img2) if fixed_img.size != img.size: w, h = fixed_img.size images.append({ 'id': image_id, 'file_name': img_name, 'width': w, 'height': h }) base_name = os.path.splitext(img_name)[0] label_file = os.path.join(label_dir, base_name + '.txt') if not os.path.exists(label_file): continue with open(label_file, 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split() if len(parts) < 5: continue cls_id = int(parts[0]) cx, cy, nw, nh = map(float, parts[1:5]) # 反归一化 x = (cx - nw / 2) * w y = (cy - nh / 2) * h bw = nw * w bh = nh * h annotations.append({ 'id': ann_id, 'image_id': image_id, 'category_id': cls_id, 'bbox': [round(x, 2), round(y, 2), round(bw, 2), round(bh, 2)], 'area': round(bw * bh, 2), 'iscrowd': 0 }) ann_id += 1 with open(output_json, 'w', encoding='utf-8') as f: json.dump({'images': images, 'annotations': annotations, 'categories': categories}, f) print(f'COCO JSON已生成: {output_json}')这里的ImageOps.exif_transpose是个容易漏掉的细节。手机拍摄的照片自带EXIF方向信息,PIL读取的尺寸和实际显示方向不一致,不做修正,转出来的COCO坐标就会整体偏移。我之前在车辆检测数据集中遇到一批手机拍的图,就是这个问题导致框全部错位。
4.4 互转时的三大“致命细节”:类别顺序、坐标系统、路径一致性
做格式互转这么多年,我总结出最容易让转换脚本出错的三大细节:
类别顺序影响模型结果,且不可见、难排查。YOLO训练时class_id完全由txt里的整数决定,classes.txt的顺序一旦和预训练权重或标注定义不一致,模型就会把A类目标当成B类学习。排查起来特别痛苦,因为训练loss看起来正常,模型也能收敛,只是预测时类别全乱。建议每次转换后,把classes.txt打印出来对照一下原始定义,肉眼确认一遍。
坐标系统搞混,是最常见的“脚本写错”原因。VOC和COCO都是像素坐标,YOLO是归一化坐标;VOC是xmin/ymin/xmax/ymax,COCO是x/y/width/height,YOLO是cx/cy/width/height。这三个坐标系互相转换的公式其实很简单,但总有人转来转去搞混归一化和绝对坐标。我的建议是把转换函数单独封装,加单元测试,比如构造一个已知的框,验证转换后再转回来坐标误差不超过1像素。
路径一致性决定了训练脚本能不能找到数据。COCO的file_name是相对路径还是绝对路径,YOLO的图片目录和标签目录是否同名,VOC里folder字段会不会影响读取——这些在转换脚本里都要统一。我吃过一次亏:COCO JSON里的file_name写的是相对路径images/xxx.jpg,训练脚本的目录层级配错一位,结果训练时一直报FileNotFoundError,我当时还以为是转换脚本的问题。
5. 转换后的验证:不验证的转换等于白转
5.1 可视化验证:把标签画回图上,一眼看出对错
转换完第一件事不是直接开训练,而是可视化。写一个小脚本,把YOLO格式的txt读出来,反归一化后画回原图,看标注框和目标的贴合程度。
import cv2 def draw_yolo_labels(img_path, label_path, classes): img = cv2.imread(img_path) h, w = img.shape[:2] with open(label_path, 'r') as f: for line in f: parts = line.strip().split() if len(parts) < 5: continue cls_id = int(parts[0]) cx, cy, nw, nh = map(float, parts[1:5]) x = int((cx - nw / 2) * w) y = int((cy - nh / 2) * h) bw = int(nw * w) bh = int(nh * h) color = (0, 255, 0) cv2.rectangle(img, (x, y), (x + bw, y + bh), color, 2) cv2.putText(img, classes[cls_id], (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1, cv2.LINE_AA) cv2.imshow('check', img) cv2.waitKey(0)可视化不只是看“有没有框”,要看三点:框是否贴合目标、类别是否正确、是否有边框异常偏移。我一般随机抽30张图,用脚本生成一张拼接大图,一次看30个样本。如果90%以上都正常,基本可以放心进训练;如果发现框普遍偏左或偏下,优先检查坐标归一化公式,而不是继续转换。
5.2 统计验证:用数据说话,避免“看起来没问题”
可视化解决“单个样本对不对”的问题,统计解决“整个数据集健不健康”的问题。我每个数据集都会输出一份统计报告:
- 图片总数、标注框总数、每张图的平均框数
- 每个类别的样本数、标注框数、框面积分布
- 是否出现空标注文件(有图片没标签)
- 是否存在“图片尺寸为0”的异常记录
这些统计指标能帮你发现几个比较隐形的问题:比如某个类别的样本数明显偏低(类别不均衡)、某些图片完全没标注(漏标严重的图)、框的面积分布过于集中在极小或极大值(可能标注不规范)。
这里我要插一句热词里提到的“yolo混淆矩阵总合不唯一”的问题。很多人训练完发现混淆矩阵行列加起来对不上,第一反应是代码bug,其实很多时候是数据集里存在重复标注、空标注文件或者类别映射错位导致的。统计验证提前发现这些,后面就不会一脸懵。
5.3 训练前的Smoke Test:转完跑一个最小训练,及时暴露隐患
可视化没问题、统计也没问题,还不够。我会在正式训练前跑一个“冒烟测试”——用一张显卡,拿整个数据集训练1个epoch(或者一个很小的batch),观察三件事:
- 数据加载阶段是否报错:图片路径错误、标签格式不合法、类别索引越界,都会在第一个batch暴露。
- loss是否正常变化:如果loss一开始就是NaN,八成是标签里有非法坐标值(比如0/0),或者是图片损坏、读取失败。
- 验证集上的基础指标:训练完一个epoch后跑一遍validation,看mAP是否有个小小的起步值。如果mAP是0且一直不动,优先怀疑标签和真值的格式不匹配。
Smoke test也就十几分钟的事,但它能把“转换错误”和“模型配置错误”快速分开,省掉后面排查弯路。
6. 实战一年踩过的坑:有些经验只能靠时间换
6.1 图片损坏和EXIF旋转:两个最隐蔽的“刺客”
第一个坑是图片损坏。转换脚本code写得很完美,但数据集里混了一张损坏的JPG,PIL.Image.open能打开(因为读取了文件头),但cv2.imread读取失败返回None,然后脚本直接崩溃。有时候不是完全损坏,而是尺寸信息和实际像素不一致,导致反归一化后的坐标偏到画面外。解决办法是在收集清洗阶段就统一用PIL.Image.verify()做一轮完整性检查,把打不开的图全部筛选出来。
第二个坑是EXIF旋转,我前面提过一嘴,这里展开讲讲。手机竖拍的照片,元数据里会记录“需要旋转90°才能显示正确方向”,但很多脚本不会自动处理这个方向信息。直接读像素时,宽和高是反的(实际是横图存成了竖图),导致YOLO转COCO时尺寸错误、框的位置偏移了90度。这个问题在公开数据集里几乎遇不到,但真实工业场景里手机拍的图一多,就非常容易中招。解决方案就是前面代码里用ImageOps.exif_transpose处理,或者干脆在收集环节对所有图统一做一次“方向标准化”,把方向信息直接写进像素里。
6.2 类别大小写和“隐藏字符”问题
另一个微不足道但实际很烦的坑是类别字符串的大小写不一致。比如同一个类别在标注时,有些图写的是bird,有些图写的是Bird,转换脚本在构建映射表时,严格区分大小写,就会导致部分目标被丢弃。我遇到过更极端的例子:某一批XML里类别名末尾被神秘地加了一个空格(通常是Excel编辑或者复制粘贴时带入的),name.strip()没做的话,这个类别就会被识别成“另一个类别”。
这类问题只要在转换脚本里统一加一行name = name.strip().lower()就能解决,但前提是你得提前意识到。我的习惯是所有类别名字在进入classes.txt之前,都做一次strip和统一大小写,避免后续各种稀奇古怪的对不上。
6.3 我的最终操作习惯:一套稳定的“固定流程”
踩过足够多的坑之后,我现在做数据集基本上是一套固定流程,每一步都是踩坑换来的:
- 收集后先清洗:完整性检查、去重、清晰度初筛。
- 类别定义写成文件:命名一个
classes.txt,开工前全员确认。 - 标注时定期抽查:每周输出一次可视化拼接图,确认标注质量稳定。
- 转换脚本固定模板:所有格式互转都用同一个脚本库,不做重复造轮子。
- 转换完成后三步验证:可视化抽检、统计报告、冒烟测试跑一个epoch。
- 数据和标签同步备份:原始标注和转换后标签分开存放,防止误覆盖。
这套流程下来,数据集制作基本可以做到“一次搞对”,而不是反复返工。
最后分享一个小技巧:如果你在做一个全新的检测项目,不知道数据集格式怎么组织最省事,直接参考你准备用的那个训练仓库的data.yaml或者配置文件——YOLOv5/v8的仓库对数据集目录和标签格式有明确要求,你按照它的规范去组织数据和转换标签,比你自己“觉得合理”地乱放要省事得多。这算是我做了十几个检测项目后最真诚的一条建议:数据集的目的是服务训练,不是让你自己看着舒服,所以先看训练框架的脸色,再动手整理数据,才是真正的“一次搞对”。