ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLOv7数据标注全流程:从LabelImg手工标注到TXT格式转换与自检

YOLOv7数据标注全流程:从LabelImg手工标注到TXT格式转换与自检

做目标检测项目,最容易被低估的永远是数据标注。我见过不少刚开始跑YOLOv7的朋友,兴致勃勃把网络结构、训练参数研究了个透,结果在标注环节草草了事,训练完一看mAP怎么都上不去,回头挨个查标注文件才发现满屏错框、漏框、类别错位。坦白讲,YOLOv7的训练流程已经成熟得接近"开箱即用",真正决定项目能不能落地的,反而是最不起眼的数据标注。这篇文章就把YOLOv7数据集标注这条线完整走一遍:用LabelImg做手工标注、把常见的VOC格式XML转成YOLO需要的TXT、按目录规范整理并划分训练验证集,最后附上一套标注自检方法。不管你是第一次碰目标检测,还是只差最后一步就能开训的老手,照着走基本不会翻车。

1. 为什么说标注质量直接决定YOLOv7训练上限

1.1 训练数据在目标检测里的真实分量

先泼一盆冷水:模型再好,喂进去的标签是错的,它会一本正经地把错误学成"标准答案"。目标检测的损失函数,核心就是拿预测框和标注框做对比,标注框错了,模型收到的监督信号就是错的。训练初期模型还在适应阶段,偶尔几个错框还能被其他正确样本稀释掉,一旦错框比例超过10%,模型的收敛方向就会被带偏,表现是训练集loss明明在降,验证集mAP却纹丝不动。

我帮A同学调过一个数据集,他怀疑是不是网络结构有问题,我把他的标签文件拉出来一看,有一个类别的id在标注中途被改乱了,之前存好的txt文件全部对不上号,那一类检测精度直接掉了十几个点。改完重新训练,什么都没动,指标立刻回来了。这种案例我见过不止一次,问题从来不在模型,在数据。

另外还有一个容易被忽略的点:类别样本不均衡。如果你的数据集里A类有5000个框,B类只有30个框,模型大概率会把B类学成A类的"背景噪声",这类问题靠调参数很难救,只能在标注阶段尽量补足和平衡。所以不要觉得标注就是"画框",它直接决定了你在训练阶段能走多远。

1.2 YOLOv7对数据集的基本要求

YOLOv7官方仓库支持两种数据输入方式,但对新手来说,只需要先搞清楚一种:一个图片对应一个同名的txt文件,txt的每一行是一个标注框。行内字段固定为5个:

类别id 归一化后的中心点x 归一化后的中心点y 归一化后的框宽 归一化后的框高

所有坐标值都除以了图片宽高,范围在0到1之间,所以叫归一化坐标。这是YOLO系列最常见的标签格式,后面章节会重点讲换算。除了标签文件,还要准备一个data.yaml配置,告诉训练脚本图片路径在哪、验证集路径在哪、类别数是多少、类别名是什么。这三个东西:图片、txt标签、yaml配置,凑齐了就可以启动训练。

有一点要提前说清楚:YOLOv7在读取训练图片时,会按约定好的规律自动去找对应的标签文件,默认规则是把图片路径中的images替换成labels。也就是说,你图片放在 dataset/images/train 下,标签就必须放在 dataset/labels/train 下,目录名不能随意改。这个细节我在第五节会详细演示,现在只要心里有个数就行。

2. 环境准备与LabelImg安装实录

2.1 为什么到现在还推荐LabelImg

市面上标注工具不少,有网页版的、有要部署服务器的、有号称AI辅助的,但我给新手推荐时,首选仍然是LabelImg。原因很简单:轻量、免费、开箱即用,而且原生支持同时导出PascalVOC格式的XML和YOLO格式的TXT,不用装一堆依赖,双击就能干活。

LabelMe是另一个常见工具,但它主打多边形标注,面向的是分割任务,用来做检测框反而绕弯。CVAT功能强大,适合团队协作,但要起服务、配环境,对一个人处理几百张图来说太笨重。云端平台也有不少好用的,但数据要上传,我认识的很多朋友对数据隐私还是有所顾虑,尤其涉及工业场景图片时。LabelImg虽然官方更新频率不高,但它的功能已经足够稳定,一款标注工具的核心需求它全都覆盖了,没必要追新。

2.2 Windows/Linux安装踩坑记录

Windows上最省事的方式是直接下载官方发布的免安装exe版,双击就打开了,连Python环境都不用配。你要是愿意用命令行装,那就走pip路线,但这一步常常踩坑:直接执行pip install labelImg,运行时报错找不到Qt相关模块,这是因为新版Python环境里没有PyQt5。解决办法是先装PyQt5再装LabelImg:

pip install pyqt5 pip install labelImg

Linux下(以Ubuntu/Debian为例),推荐先装系统级的Qt依赖,再pip安装:

sudo apt install pyqt5-dev-tools python3-pyqt5 pip3 install labelImg

如果装完还是启动不了,多半是系统里同时存在多个Python版本,路径混乱。这时候别硬调,直接创建一个干净的虚拟环境最稳:

python3 -m venv label_env source label_env/bin/activate pip install pyqt5 labelImg labelImg

个人建议:不管哪个系统,都尽量用Python 3.8到3.10之间的版本,太新的Python偶尔会和PyQt5出现兼容问题,太旧的则可能装不上依赖。如果你只是偶尔标注一次,就直接用exe版或系统安装包,别折腾环境,把时间留给画框。

2.3 首次启动与界面功能拆解

打开LabelImg后,界面比想象中朴素:左边竖排工具按钮,中间是图片显示区,右边是标注列表。先记住几个最常用的按钮位置:Open打开单张图、Open Dir打开整个文件夹、Change Save Dir设置标签保存目录。中间显示区下面还有一个"PascalVOC/YOLO"切换按钮,这个按钮决定你保存时生成XML还是TXT,新手最容易在这里犯迷糊。

快捷键是我最看重的部分,标注效率全靠它:

  • W:在当前图片上画一个矩形框
  • A / D:切换上一张 / 下一张图片
  • Ctrl + S:保存当前标注
  • Ctrl + Shift + S:另存为
  • Delete:删除选中的框
  • Ctrl + Z:撤销上一个框

画框的操作逻辑是这样:按W后鼠标在图上拖出一个矩形,松开就弹一个类别选择框,你可以从下拉列表里选已有类别,也可以直接输入新类别。选完类别后,这个框会出现在右侧的Box Labels列表里。然后再按Ctrl+S,保存的瞬间这个框就被写入标签文件了。

开始标注之前,一定要先在数据目录下建好一个classes.txt之类的类别清单。更实际的做法是,标注完第一张图、把所有类别都加齐之后,再逐张往下走。如果你中途频繁新增类别,尤其是用YOLO模式直接存TXT时,类别id是按顺序排的,中途插入新类别会让老文件全部错位,这是新手翻车率最高的问题,后面我会专门说。

3. 从零开始完成一张图的标注全过程

3.1 标注前的图像整理与命名规范

很多人拿到图片就往LabelImg里一拖,这是不对的。开工之前,先用十分钟把图像目录整理好,能省后面两个小时的返工时间。

第一,图片命名必须"干净"。不要用中文名,不要含空格,不要包含特殊符号。原因很现实:很多框架和OpenCV读取中文路径偶尔会出问题,报错时你根本分不清是路径的锅还是标签的锅。规范的命名类似 image_0001.jpg 这种,建议统一用补零的数字,这样文件排序也稳定。

第二,把模糊的、严重过曝的、明显拍坏的图片先筛掉。这类图模型学不出有效特征,标注时你还得硬着头皮画框,纯属浪费人力。

第三,分类别统计一下图片里目标的大致分布。比如你手头900张图里800张只有A类,B类只出现在20张里,趁还没开始标注,先去补充B类图片,好过标完再补。

第四,也是最容易被忽略的,写一页"标注规则"。规则里写清楚每个类别到底包括哪些东西:比如"安全帽"是否包含布帽、"车辆"是否包含自行车、"行人"被遮挡一半时要不要标。这不是形式主义,是人脑在标注300张图之后一定会开始"凭感觉"标,规则是让你对抗疲劳和主观漂移的。

3.2 实际操作:框选、类别、保存

整理好图片后,按这个顺序操作:

  1. 点Open Dir选择图片文件夹;
  2. 点Change Save Dir选择标签保存文件夹;
  3. 先想好这次用XML还是TXT,点界面上那个格式按钮切到对应模式,确定后就不要中途切换;
  4. 按W,在目标物体上拖出矩形框;
  5. 选择或输入类别名;
  6. 按Ctrl+S保存当前图片的标注;
  7. 按D跳到下一张图,重复以上操作。

每一步看起来都很简单,但有个细节我要专门强调:每一次画完框,都要养成"随手保存"的习惯。LabelImg里有自动保存选项可以打开,但我实测过,每次按完Ctrl+S再切图的肌肉记忆,比什么都可靠。标注是高度重复的机械劳动,中间被打断一次,回来就忘了上一张存没存,这种低级事故我见过太多次。

3.3 标注技巧:边界框该贴多紧

这是标注里争议最多的问题:框到底应该画多紧。我的原则很简单——框要贴合目标的主轮廓,不含背景、不切进物体。

说得具体一点。标一个站立的人,框的上下左右边界应该贴着人的外沿,头顶上方留一两个像素可以接受,但绝不能把大片天空和地面圈进来。框太松,会引入大量背景信息,模型学到的特征就脏了;框太紧,把人的边缘裁掉一部分,模型又学不到完整的轮廓信息。理想状态是"比物体外沿略微紧一点但不能切到主体"。

遇到遮挡目标,标注可见部分。两个物体叠在一起,就分别标两个框,每个框贴着各自可见的轮廓。如果目标在图片边缘,被裁掉一半以上,我通常建议不标,因为这种样本的框信息严重不完整,模型学到的是残缺形状,对推理没帮助;但如果只是裁掉一小块,主体还在画面里,那就按可见范围把框画完整。

还有一个很多人纠结的问题:小目标要不要标。我的回答是必须标,而且越小的目标越要标准。模型漏检小目标,最直接的原因就是训练时小目标的框数量不足、位置不准。一个小到只有几个像素的框,哪怕偏了半个框,交并比就掉到谷底,梯度更新直接被当成负样本处理了。

3.4 标注中途最容易犯的三个错

标注这件事,错法高度雷同。我自己用LabelImg标过上万张图,也帮朋友排查过各种奇奇怪怪的数据问题,最常撞见的是下面这三种:

第一个错,中途改类别顺序。YOLO模式下txt只记录类别id,不记录类别名,id完全按你在标注工具里添加类别的顺序决定。今天标了"猫""狗",明天觉得顺序不好调整成"狗""猫",那之前存的所有txt全部作废,训练时模型学到的类别名和实际内容完全错位。对策只有一个:开工前把整个数据集的类别清单一次定死,整个标注周期内不许变。

第二个错,不换保存目录就开始画框。LabelImg默认保存目录可能指向上一次项目的路径,结果你标了一下午,标签全写到了另一个文件夹里,图片这边看着每张都有框,其实一个txt都没生成。每次打开软件第一件事,就是确认右上角的保存路径。

第三个错,图片和标签文件数量对不上还硬训。标完一批图,有些人直接复制粘贴到训练目录就跑了,结果有几张图漏了txt,有几张txt是0字节空文件。YOLOv7训练时遇到"有图无标签"会直接报错或者干脆跳过这张图,数据量一少模型就学不到位。这个问题的解法,就是我第六节要说的自检流程,训练前必须跑一遍。

4. XML到YOLO格式转换,手写脚本比工具更靠谱

4.1 VOC格式和YOLO格式到底差在哪

很多现成的公开数据集,比如部分开源检测数据集,下载下来是PascalVOC格式:每张图片对应一个XML文件,里面用xmin、ymin、xmax、ymax这种像素坐标描述框的位置。而YOLOv7只认TXT格式的归一化坐标,所以格式转换是绕不开的一步。

这两种格式的核心差异在于坐标表达方式:

项目VOC(XML)YOLO(TXT)
坐标类型像素坐标,绝对值归一化坐标,相对值
框表达xmin、ymin、xmax、ymax中心点x、中心点y、宽、高
每条记录一个object标签节点一行5个数字
类别表达name字符串类别id数字

为什么要用归一化坐标?因为同一张图训练时会resize到固定尺寸,比如640x640,像素坐标在缩放后必须跟着改;而归一化坐标本身是比例,不管图片缩到多大,0.5仍然是中心点,标签完全不用动。这就是YOLO系列选择归一化坐标的根本原因。

4.2 转换脚本逐行拆解

转换工具网上有很多,但我一向建议自己写脚本。原因很实在:现成工具通常绑定了固定的XML结构,你拿到的数据集如果字段略有不同,工具直接罢工;自己写的脚本出了问题,你能看懂、能改。下面是我常用的转换脚本,逻辑非常直白:

import os import xml.etree.ElementTree as ET def voc_xml_to_yolo_txt(xml_path, txt_dir, class_list): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find('size/width').text) img_h = int(root.find('size/height').text) lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in class_list: continue class_id = class_list.index(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) x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h box_w = (xmax - xmin) / img_w box_h = (ymax - ymin) / img_h lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}") txt_name = os.path.splitext(os.path.basename(xml_path))[0] + '.txt' with open(os.path.join(txt_dir, txt_name), 'w') as f: f.write('\n'.join(lines))

使用方式也很简单:class_list就是你在标注阶段定死的类别清单,顺序绝对不能变,因为转换后写入txt的就是这个顺序对应的id。调用时遍历整个XML文件夹,为每个XML生成一个同名的TXT即可。这里我用了Python标准库的xml.etree.ElementTree,零额外依赖,任何环境都能跑。

4.3 归一化坐标换算的细节与边界情况

换算公式本身不复杂,但有几个细节值得单独讲。第一,x_center和y_center是取xmin和xmax的中点之后再除以图宽,不是直接把中点当作比例就完事,必须保证除以的是真实的图片宽高,而这个宽高来自XML里的size节点。假如XML里记录的尺寸和实际图片不一致——比如图片之前被resize过但XML没更新——转换出的所有坐标都会整体偏移,整个数据集基本报废。

第二,保留6位小数就足够了。归一化坐标的精度在6位小数下已经远超实际需求,更多的小数位只会让文件体积变大,没有任何收益。

第三,如果转换时发现某个框的xmin大于xmax,说明XML本身是坏的,这种文件要单独拎出来修,而不是硬转换。另外还要警惕宽或高计算出来是0的情况,这种框在训练时会导致损失计算异常,我在第六节的自检部分会教你一条命令把它们揪出来。

第四,也是最容易忽略的:转换脚本运行前,先确认你的XML里没有"未标注但存在"的空object节点,有些工具会在XML里留下空的object标签,这会让转换脚本报错或生成垃圾行。脚本里加一个判断:只有bndbox四个值都大于0才写入,能挡掉不少脏数据。

5. 按YOLOv7目录规范组织数据集并划分训练集

5.1 YOLOv7的数据目录结构

格式转换完成之后,下一步是把散落的图片和TXT按YOLOv7的约定组织起来。我推荐的结构长这样:

dataset/ ├── images/ │ ├── train/ │ │ ├── image_0001.jpg │ │ └── ... │ └── val/ │ ├── image_0501.jpg │ └── ... └── labels/ ├── train/ │ ├── image_0001.txt │ └── ... └── val/ ├── image_0501.txt └── ...

前面说过,YOLOv7会自动把图片路径中的images替换为labels去查找标签,所以images和labels必须是同级目录下的兄弟关系。train和val两边都要成对出现,千万不能出现"训练集有标签、验证集没标签"这种不对称,否则验证阶段直接报错。

很多从VOC数据集入手的同学习惯用train.txt和val.txt这两个文本文件,里面一行一个图片路径。YOLOv7也支持这种写法,data.yaml里直接指向这个txt文件就行。但我的建议是:如果不是特别原因,直接采用images/labels兄弟目录结构,这是最直观、最不容易出错、也最好迁移到其他YOLO系列版本的方式。

5.2 训练集/验证集划分脚本

划分训练集和验证集,别手动拖文件,一定要用脚本。手动划分最大的问题,是图片和TXT容易落进不同的目录,一旦错位,训练出来的模型指标毫无意义。下面这个脚本用随机种子固定随机顺序,保证可复现:

import os import random import shutil src_img = 'all_images' src_txt = 'all_labels' dst_root = 'dataset' train_ratio = 0.9 seed = 42 random.seed(seed) images = [f for f in os.listdir(src_img) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] random.shuffle(images) split_idx = int(len(images) * train_ratio) train_images = images[:split_idx] val_images = images[split_idx:] for split_name, img_list in [('train', train_images), ('val', val_images)]: img_dst = os.path.join(dst_root, 'images', split_name) txt_dst = os.path.join(dst_root, 'labels', split_name) os.makedirs(img_dst, exist_ok=True) os.makedirs(txt_dst, exist_ok=True) for img_name in img_list: stem = os.path.splitext(img_name)[0] shutil.copy(os.path.join(src_img, img_name), img_dst) txt_name = stem + '.txt' txt_src_path = os.path.join(src_txt, txt_name) if os.path.exists(txt_src_path): shutil.copy(txt_src_path, txt_dst) else: print(f'警告: 图片 {img_name} 缺少标签文件 {txt_name}')

这段脚本最值得注意的地方,是它以图片文件名为基准来切分,然后去对应目录里找同名TXT,而不是分别随机切图片和标签。这样从机制上杜绝了图文错位的问题。我习惯把train_ratio设成9:1,数据量大时可以调到95:5,数据量小的时候至少也要留出5%做验证,否则验证集太薄,mAP的波动会让你完全无法判断模型好坏。

5.3 data.yaml配置文件的必填字段

目录就绪后,写一份data.yaml。这是训练脚本唯一会读取的数据配置,内容很简单:

train: dataset/images/train val: dataset/images/val nc: 3 names: ['cat', 'dog', 'bird']

train和val的路径,相对于你执行train.py时所在的目录来写。如果你把YOLOv7仓库和dataset目录放在同一个项目根目录下,那上面这样写就是对的。nc是类别数量,names是类别名字列表,它有一个硬性要求:names的排列顺序必须和TXT里的类别id顺序完全一致。TXT里id为0的框,对应names[0];id为1的,对应names[1],以此类推。一旦这里错位,模型训练出来会把猫识别成狗,而且你很难发现,因为loss一切正常。

还有个容易踩的细节:names里不要有重复项,不要有中文,不要有空格。这些都会在后续的绘图、评估环节引发莫名其妙的报错。写好yaml之后,启动训练的命令一般是这样的:

python train.py --data data.yaml --weights yolov7.pt --img 640 --batch 8

第一次训练不要贪大参数,先把流程跑通再说。

6. 标注质量自检与常见坑位排查

6.1 五步自检法

就算标注过程再小心,也挡不住人脑的疲劳,所以训练之前,自检环节必须走一遍。我总结了一套五步自检法,每一步都可以用脚本快速执行,成本极低,但能把训练阶段的返工几率降一大截。

第一步,图文一一对应检查。统计images目录和labels目录下的文件名,找出"有图无标签"和"有标签无图"的文件,打印出来逐一处理。

第二步,标签内容格式检查。每个TXT的每一行必须是5个数字,且后4个数值都在0到1之间。超过1或者小于0,说明坐标有问题;行内字段数不对,说明文件损坏。

第三步,可视化抽检。这是最直观也最有效的一步,把TXT坐标画回图片上,人眼扫一遍就什么都清楚了。下面的脚本可以快速实现:

import cv2 import os img_dir = 'dataset/images/val' txt_dir = 'dataset/labels/val' names = ['cat', 'dog', 'bird'] for img_name in os.listdir(img_dir): stem = os.path.splitext(img_name)[0] txt_path = os.path.join(txt_dir, stem + '.txt') if not os.path.exists(txt_path): continue img = cv2.imread(os.path.join(img_dir, img_name)) h, w = img.shape[:2] with open(txt_path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cid, x, y, bw, bh = map(float, parts) x1 = int((x - bw / 2) * w) y1 = int((y - bh / 2) * h) x2 = int((x + bw / 2) * w) y2 = int((y + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2) cv2.putText(img, names[int(cid)], (x1, max(0, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) cv2.imshow('check', img) if cv2.waitKey(0) & 0xFF == ord('q'): break cv2.destroyAllWindows()

跑完这个脚本,按任意键翻下一张。重点看:框有没有明显错位、类别文字和框内物体是否一致、有没有漏标了大目标。

第四步,分类别数量统计。写一个小脚本统计每个类别的框总数和包含该类别的图片数。如果某个类别的数量少到离谱,就要考虑补数据,而不是硬训。

第五步,训练初期的健康度检查。训练启动后,看前几十个epoch的loss曲线和验证集mAP。如果loss在快速下降但mAP纹丝不动,大概率是标注里有系统性错误,这时候别继续挂着训练了,回头查数据。

6.2 常见报错与对应处理

训练时遇到的报错,有很大一部分根源在数据和标注上。下面列几个我实际遭遇过的高频问题:

报错或现象常见原因处理方式
No labels found 或 cannot load labels路径结构不符合images/labels约定检查目录结构,确认labels与images同级
index out of range / class id超范围TXT里的类别id大于data.yaml的nc-1检查TXT第一列数值,修正id或同步更新names
中文路径报错图片或标签路径含中文全部改为英文路径
训练loss初期就出现NaN标签中有0尺寸框或坐标越界跑一遍格式自检脚本,删掉异常行
单个类别mAP始终为0该类别标签id错位或样本过少可视化抽检确认标签与物体是否匹配

这些报错有个共同点:报错信息往往不直接指向标签问题,而是伪装成"路径错误"、"显存不足"、"模型不收敛"。所以我的经验是,遇到诡异问题先别调模型和参数,花十分钟查一遍标签再做决定。

6.3 标注外包与半自动标注的现实建议

如果你的数据集规模上千甚至上万张,纯手工标注确实不现实,这时候有两个现实方案。

一个是半自动标注。先手动标几百张相对有代表性的图,训练出一个初步模型,再用这个模型的预测结果给剩余图片打上初始框,最后人工在预测框基础上微调和删除。这个方法能省下至少一半标注时间,但前提是初步模型的质量不能太差。如果你是从零开始且图片场景复杂,直接上这个方案反而可能越标越乱,不如老老实实全手工。

另一个是找外包标注团队。这里我的建议非常直接:外包之前,把你的标注规则文档发过去,明确要求对方交付VOC格式的XML还是YOLO格式的TXT,同时让他们先试标2到3张图,你检查无误后再放量。验收时走我上面说的五步自检法,尤其是可视化抽检,不要只看文件是否齐全就付款。我见过有人贪便宜把标注外包出去,结果一张图上标了重复框、类别张冠李戴,返工成本比亲自标注还高。

这套流程我前后跑过不下十次,最深的体会是:工具从来不是瓶颈,规范和自检才是。真正让我在项目里少加班的,不是更快的标注软件,而是开工前写清楚的那页标注规则,和训练前那半个小时的检查脚本。建议你第一次做的时候,不要急着标几千张,先标三五十张,用可视化脚本把框画出来仔细看看,确认规则没有歧义再多画。这个习惯,比任何标注技巧都值钱,至少我后来每次项目都靠它躲过了最耗时的返工。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进