简介:一套面向深度学习目标检测任务的高质量手工标注人车识别VOC数据集,内含1000张真实场景图像与对应XML标注,严格遵循PASCAL VOC标准格式,可直接用于YOLO、SSD、Faster R-CNN等主流检测模型的训练与验证。压缩包共1994个文件,其中997个xml标注文件详细记录行人、车辆的位置边界框、类别和图片尺寸等关键信息,729张jpg与268张png为原始图片,整体约711MB。目录明确划分为dataset与Annotations两部分,图像与标注一一对应,可按需切分训练集和验证集,无需额外转换格式,方便直接接入现有训练流程。作者亲测训练后检测效果良好,纯手工标注,边界框贴合物体边缘,类别判断一致,能有效减少数据清洗与复核时间,十分适合作为项目起步阶段的基准数据。目前已有1035人学习下载,适合具备深度学习基础的开发者用于人车检测模型训练、算法对比及性能评估,也可帮助快速搭建行人车辆识别系统。
1. 手工标注的人车识别数据集:为什么 1000 张比一万张更有用
做目标检测的人,一开始都会陷入“数据越多模型越强”的错觉。可当你真的去爬图、半自动打标、清洗,最后能留下的人车识别图片可能连一千张都不到。这份 VOC 格式的手工标注数据集一共 1000 张,每张 JPEG 图片都配一个独立的 XML 标注文件,人和车的位置全是逐框确认过的。我用它在 YOLOv8 和 YOLOv5 上做过迁移学习,同样用 640 分辨率训练,效果比同数量级的自动标注数据高一个档次,尤其是在遮挡和暗光场景下不会乱框。适合谁?想快速验证检测流程、不想从零标框的开发者,以及需要一份干净数据做基准测试的学生。它的价值不在数量,而在每一条标注的置信度。
2. VOC 格式的真相:XML 文件里到底存了什么
新手拿到数据集,第一反应是“里面是不是有现成的 txt 标签”。实际上这份资源的核心是 Annotations 目录下的一组 XML 文件,它们遵循 PASCAL VOC 的标注规范。不理解这个格式,后面转 YOLO、调训练脚本都会卡壳。这一章把 XML 的字段结构、目录对应关系和批量解析方法拆开,让你在动手训练之前先摸清数据底细。
2.1 一个 XML 标注文件的完整结构
用 LabelImg 等工具打开一张图片,比如 family_sedan_126.jpg,它的同名 XML 文件内容大致如下:
<?xml version="1.0" encoding="UTF-8"?> <annotation> <folder>JPEGImages</folder> <filename>family_sedan_126.jpg</filename> <path>E:/dataset/JPEGImages/family_sedan_126.jpg</path> <source> <database>Unknown</database> </source> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <segmented>0</segmented> <object> <name>car</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>320</xmin> <ymin>250</ymin> <xmax>800</xmax> <ymax>620</ymax> </bndbox> </object> <object> <name>person</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>100</xmin> <ymin>150</ymin> <xmax>200</xmax> <ymax>400</ymax> </bndbox> </object> </annotation>这棵树的根部是<annotation>,它下面挂着图片的基本描述和若干个<object>块。每个<object>代表一个标注目标,<name>是类别标签,在这里只有 person 和 car 两种;<bndbox>是核心,xmin、ymin、xmax、ymax 分别代表目标框左、上、右、下四个边界在原始图像中的绝对像素坐标。注意这是绝对坐标,不是归一化值,宽度就是 xmax - xmin,高度就是 ymax - ymin。
理解<difficult>字段很重要。当目标本身被严重遮挡、只有很小一部分露在外面时,标注者会把它标记为 difficult=1。VOC 的规则是 difficult 目标参与计算但不作为主要评价对象,而 YOLO 系模型没有对应的忽略机制,所以转换时通常会直接跳过这类框。<truncated>表示目标是否超出图像边界,对自动驾驶场景有参考价值,但用于普通训练时可以忽略。
这份数据被称为“手工标注高质量”,从 XML 里就能看出痕迹:比如同一个人的框,在不同图片里边缘都贴着身体轮廓,不会出现大片留白;车辆框也不会把后视镜和轮胎切掉一半。这种一致性是后续训练收敛速度的关键,也是自动标注脚本很难做到的。
2.2 目录组织:JPEGImages 与 Annotations 的一一对应
这份资源解压后通常看到两个顶层目录:dataset下的JPEGImages(或 images)存放原始图片,Annotations存放同名 XML。每个图片文件名和 XML 文件名必须完全一致,只允许扩展名不同,否则训练工具按 stem 匹配时会找不到标签。
| 目录 | 内容 | 命名示例 |
|---|---|---|
| dataset/JPEGImages | 原始 JPG 图片 | family_sedan_126.jpg |
| dataset/Annotations | 对应的 VOC XML 标签 | family_sedan_126.xml |
| dataset/labels(可选) | 转换后的 YOLO txt | family_sedan_126.txt |
拿到手第一件事不是训练,而是校验文件对应关系。我一般用一段 bash 命令做 diff:
ls dataset/JPEGImages | sed 's/\.jpg$//' | sort > images.txt ls dataset/Annotations | sed 's/\.xml$//' | sort > annots.txt diff images.txt annots.txt如果 diff 输出为空,说明一一对应;如果有输出,多出来的或缺少的项就是坑。sed 's/\.jpg$//'的作用是把 jpg 后缀剥掉,只保留文件名主干,然后分别生成两个清单对比。这里要求所有图片都是 jpg,如果混有 png 或 jpeg,需要先把扩展名统一,否则匹配逻辑会错。我经常遇到的情况是有人把图片重命名成family_sedan_126(1).jpg,同时 XML 没改名,这一行直接暴露问题。
2.3 为什么选 VOC 而不是直接存 YOLO txt
很多入门者会问:既然要训练 YOLO,为什么不直接给 txt 文件?原因在于 VOC 的 XML 是半结构化文本,人眼可读、可手动修改,而 YOLO txt 是五个一行的纯数字,出问题根本看不出是哪个目标的框。VOC 格式相当于一个中间交换层,既能被 LabelImg、labelme 等工具读取,又能转换成 COCO、YOLO、CSV 等任何训练格式。下表是三种常见格式的对比:
| 格式 | 存储载体 | 坐标表示 | 可读性 | 适配训练框架 |
|---|---|---|---|---|
| VOC | XML | 原始像素的 xmin/ymin/xmax/ymax | 高 | 几乎所有检测框架 |
| YOLO txt | 文本文件 | 相对宽高的 x_center/y_center/w/h | 低 | YOLO 系列专用 |
| COCO | JSON | 原始像素的 x/y/w/h | 中 | Detectron2、MMDetection 等 |
所以我的习惯是:数据集以 VOC 格式保存和存档,训练前用脚本转换成 YOLO txt,而不是反过来。因为 XML 里保留了difficult、truncated这些附加信息,丢掉后很难再找回。这也是这份资源保留 Annotations 目录的价值所在。如果直接给你 txt,你反而没法判断哪些框是难例,哪些框被人工修正过。
2.4 用 Python 批量解析 XML:先看数据再训练
看单张 XML 可以了解格式,但要快速掌握整份数据的类别分布、框大小分布,写个小脚本更高效。下面这段代码读取 Annotations 下所有 XML,统计 person 和 car 的数量以及每张图的宽高:
import xml.etree.ElementTree as ET import glob from collections import Counter def parse_voc(xml_path): root = ET.parse(xml_path).getroot() width = int(root.find('size').find('width').text) height = int(root.find('size').find('height').text) objects = [] for obj in root.findall('object'): name = obj.find('name').text difficult = obj.find('difficult').text if obj.find('difficult') is not None else '0' bbox = obj.find('bndbox') xmin = int(float(bbox.find('xmin').text)) ymin = int(float(bbox.find('ymin').text)) xmax = int(float(bbox.find('xmax').text)) ymax = int(float(bbox.find('ymax').text)) objects.append({'name': name, 'difficult': int(difficult), 'bbox': (xmin, ymin, xmax, ymax)}) return width, height, objects class_counter = Counter() diff_counter = Counter() widths = [] for xml_file in glob.glob('dataset/Annotations/*.xml'): w, h, objs = parse_voc(xml_file) widths.append(w) for o in objs: class_counter[o['name']] += 1 if o['difficult'] == 1: diff_counter[o['name']] += 1 print("类别数量:", dict(class_counter)) print("难例数量:", dict(diff_counter)) print("平均图片宽度:", sum(widths) / len(widths))这段脚本的重点在于容错处理:difficult字段不是每个标注软件都会写,所以用if ... is not None兜底;bndbox里的坐标虽然是浮点字符串,但通常写成整数,用int(float(...))转换可以同时兼容“320”和“320.0”两种写法。跑完后你会看到类似{'person': 620, 'car': 910}的输出,如果某个类别数量很少,就需要在训练前考虑数据增强或类别权重,而不是直接硬训。
另外,我建议顺便统计一下每张图的平均标注数。如果很多图片只有 1 个目标,模型很难学到多人多车的场景语义;如果单张图平均超过 8 个目标,说明有些框可能太拥挤,训练时 NMS 后容易漏检。这类信息对后续划分训练集和验证集很有帮助。
3. 从 VOC 到 YOLO:格式转换脚本与四个边界坑
训练 YOLO 前必须把 XML 转成 txt,因为 YOLO 系工具只认归一化后的中心点格式。这一章给一个可以直接跑的转换脚本,并把最容易翻车的四个边界问题单独拎出来讲清楚。很多人一转完就直接开训,结果 loss 是 NaN,或者框整体偏移,都是在这步埋下的雷。
3.1 转换脚本:坐标归一化与类别映射
下面的脚本遍历 Annotations 下所有 XML,生成与图片同名的 txt 文件,存放到yolo_labels目录:
import xml.etree.ElementTree as ET import glob import os from pathlib import Path voc_root = 'dataset' save_dir = 'yolo_labels' class_mapping = {'person': 0, 'car': 1} # 顺序决定训练时的类别 id if not os.path.exists(save_dir): os.makedirs(save_dir) for xml_file in glob.glob(os.path.join(voc_root, 'Annotations', '*.xml')): root = ET.parse(xml_file).getroot() img_w = int(float(root.find('size').find('width').text)) img_h = int(float(root.find('size').find('height').text)) lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in class_mapping: continue diff = int(obj.find('difficult').text or 0) if diff == 1: continue 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) x_center = ((xmin + xmax) / 2) / img_w y_center = ((ymin + ymax) / 2) / img_h box_w = (xmax - xmin) / img_w box_h = (ymax - ymin) / img_h # 边界保护:归一化后的值必须落在 [0,1] 区间 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) box_w = min(max(box_w, 0.0), 1.0) box_h = min(max(box_h, 0.0), 1.0) lines.append(f"{class_mapping[name]} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}") txt_out = os.path.join(save_dir, Path(xml_file).stem + '.txt') with open(txt_out, 'w') as f: f.write('\n'.join(lines))脚本逻辑并不复杂:先读 XML 里的图片宽高,再逐个 object 取坐标做归一化,最后按照 YOLO 的class x_center y_center width height格式写成一行。这里有几个参数需要注意。
class_mapping的字典顺序直接决定 txt 里的类别 id,也是后面 data.yaml 的names顺序,必须严格一致。常见做法是把出现频率高的类别放前面,比如car: 0, person: 1,这样在不确定时模型默认输出 id 0 也容易解释。difficult字段按上一章说的,转换时跳过,避免训练时引入更多不确定性。归一化公式用的是中心点坐标除以图片宽高,不是左上角除以宽高,这个别写错,否则框会整体偏移一个身位。
3.2 验证转换结果:反向检查框是否对得上
生成 txt 后不能直接用,得先随机挑几张图把框画回去,确认坐标没有因整数除法或越界保护而出错。
import cv2 import random import glob from pathlib import Path img_files = random.sample(glob.glob('dataset/JPEGImages/*.jpg'), 5) for img_file in img_files: img = cv2.imread(str(img_file)) h, w = img.shape[:2] txt_file = Path('yolo_labels') / (Path(img_file).stem + '.txt') if not txt_file.exists(): continue for line in open(txt_file): parts = line.strip().split() cls = int(parts[0]) x_c = float(parts[1]) * w y_c = float(parts[2]) * h bw = float(parts[3]) * w bh = float(parts[4]) * h x1 = int(x_c - bw / 2) y1 = int(y_c - bh / 2) x2 = int(x_c + bw / 2) y2 = int(y_c + bh / 2) color = (0, 0, 255) if cls == 0 else (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, 'person' if cls == 0 else 'car', (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imwrite('check_' + Path(img_file).name, img)这步的意义是验证从像素坐标到归一化坐标的往返转换没有丢信息。跑完检查生成的check_*.jpg,如果框的边角明显靠近图片边缘但内容仍在图内,说明转换成功。如果框画到了图外或者错位,优先检查是不是xmax/xmin读取顺序写反了。另一个常见错误是忘记把x_c乘回w,导致所有框都缩在左上角小方块里。
3.3 转换时的四个边界坑
手工标注的数据长得再干净,转换时也会遇到几种常见畸形。
第一个坑是坐标越界。部分 XML 的 bndbox 会写成小数点后带很大值的数字,或者 xmax 恰好比图片宽度多 1 像素。直接除以宽度后数值会是 1.0005,YOLO 训练时会报“all values must be in [0,1]”之类的错。解决方法是上面脚本里的 clip 操作,归一化后强制压到 0~1,丢失的那 0.0005 对检测精度没有影响。
第二个坑是宽高为 0。偶尔出现一个 object 的 xmin 等于 xmax,可能是手滑点成了单点。这种框在计算中心点时没问题,但宽高为 0 会让 loss 退化成 NaN。我的习惯是在转换前过滤掉宽或高小于 1 像素的框,宁缺毋滥。你可以加一句if box_w <= 0 or box_h <= 0: continue。
第三个坑是 difficult 的处理。有些标注人员习惯把严重遮挡的目标标为 difficult=1 但 bndbox 仍然很大,如果转换时不去掉,模型会被这些“半个人”干扰,尤其是 person 类别。统一过滤是最省心的。但注意,如果整个数据集所有遮挡目标都被标为 difficult,过滤后 person 数量会骤减,这时候反而要考虑把它们作为难例单独建一个验证集。
第四个坑是 XML 内 filename 与实际文件不一致。比如 XML 里写的是family_sedan_126.JPG,但磁盘上是小写 jpg;或者 XML 里多了完整路径。这时候如果以 filename 字段去匹配图片会失败。我一般不用 XML 的 filename,而是直接用 XML 文件名作为 stem,再去找同名图片,这样就能避开大小写和路径问题。上面的脚本就是这种做法,也是我踩过无数次后的固定套路。
3.4 用脚本统计转换后的标签分布
转换完成后,再快速统计一下每个 txt 的行数和类别,确保没有出现空的 label 文件:
from collections import Counter import glob stats = Counter() empty_files = [] for txt in glob.glob('yolo_labels/*.txt'): lines = [l.strip() for l in open(txt) if l.strip()] stats['总标注数'] += len(lines) for line in lines: cls = int(line.split()[0]) stats[f'cls_{cls}'] += 1 if len(lines) == 0: empty_files.append(txt) print(stats) print("空标签文件:", empty_files)如果一个 txt 里没有任何标注行,对应的图片就成了纯背景。少量这种图片可以作为负样本,但如果超过 5% 就说明标注有遗漏,需要回炉检查。统计里还要留意类别 id 的分布,如果cls_1数量很少,后面训练时就要考虑按类别加权或者做复制增强。
4. 用这份数据训练 YOLO:从配置文件到 mAP 验证
数据转好了,下一步就是喂给 YOLO。这里以 YOLOv8 为例,但目录结构和 data.yaml 的思路适用于 YOLOv5、YOLOv7 等系列。重点不是跑通,而是学会把一个小数据集用好。
4.1 组织训练目录:images 和 labels 的配套结构
YOLO 训练时默认从 images 路径推导 labels 路径。假设我们把划分后的图片放在dataset/images/train和dataset/images/val,那么 labels 必须放在dataset/labels/train和dataset/labels/val,否则框架会提示找不到标签。建议先按比例划分数据,再移动对应 txt:
import os import random import shutil from pathlib import Path src_images = Path('dataset/JPEGImages') src_labels = Path('yolo_labels') for split, ratio in [('train', 0.8), ('val', 0.2)]: (Path('dataset/images') / split).mkdir(parents=True, exist_ok=True) (Path('dataset/labels') / split).mkdir(parents=True, exist_ok=True) all_images = list(src_images.glob('*.jpg')) random.seed(42) random.shuffle(all_images) val_count = int(len(all_images) * 0.2) val_images = all_images[:val_count] train_images = all_images[val_count:] for img in val_images: shutil.copy(img, f'dataset/images/val/{img.name}') label = src_labels / (img.stem + '.txt') if label.exists(): shutil.copy(label, f'dataset/labels/val/{label.name}') for img in train_images: shutil.copy(img, f'dataset/images/train/{img.name}') label = src_labels / (img.stem + '.txt') if label.exists(): shutil.copy(label, f'dataset/labels/train/{label.name}')这里 80/20 的划分比例是目标检测里的常见做法,随机种子固定为 42 是为了让结果可复现。如果你后续要做模型对比,这个随机种子不要随意改,否则每次训练集不同,评测结果没有可比性。还有一点:划分时只复制不移动原文件,这样原始数据集还保留一份完整备份,需要重新划分或追加数据时不用从零再搞。
4.2 编写 data.yaml:类别顺序必须和转换脚本一致
YOLO 需要一个 yaml 文件描述数据路径和类别名。这个文件放在训练项目根目录下即可:
# dataset.yaml train: dataset/images/train val: dataset/images/val nc: 2 names: 0: car 1: person注意names的索引顺序必须和上一章class_mapping里定义的一致。我在转换时把 car 放在 0、person 放在 1,这里就必须是 0: car、1: person。顺序一旦错位,训练出来的模型会把人和车互相认反,而且这种错误很难从 loss 上发现。另一个容易忽略的点是路径写法。如果训练命令在项目根目录执行,用相对路径没问题;如果换到别的目录执行,建议改成绝对路径,否则训练刚开始就会报 FileNotFoundError。
4.3 训练命令与关键参数:imgsz、batch、epochs 怎么设
1000 张图不算多,训练时优先用小模型、低分辨率起步。我的建议是先跑一轮短训看收敛趋势,再上完整训练:
yolo detect train data=dataset.yaml model=yolov8n.pt \ epochs=30 imgsz=640 batch=16 patience=10 device=0参数说明:imgsz用 640 是 YOLO 系列的默认值,如果显存吃紧可以降到 416,但小目标(远处行人)的检测效果会明显下降;batch根据显存调整,16 在 8G 显存上基本够用,batch 太小会导致 BN 统计不稳定;patience是早停阈值,验证集 mAP 连续 10 轮不提升就停止,省时间。对于手工标注的干净数据,30 轮足够看到模型是否在学,正常会在第 10 到 20 轮之间开始明显收敛。
如果训练过程中 loss 持续下降但验证集 mAP 不涨,多半是过拟合了。小数据集最常见的现象是:训练 loss 降到 0.01,验证 loss 却回升。这时候不要盲目加 epochs,而是回看数据划分是否随机、验证集是否包含了与训练集过于相似的图片。像这种手工标注数据,如果拍摄场景高度集中(比如同一个停车场拍了很多角度),验证集就会出现“假的高分”,换到真实场景就垮掉。所以划分前最好按图片来源或场景打散,而不仅是简单随机数。
4.4 评估结果:mAP50 和 mAP50-95 从哪看
训练完成后,Ultralytics 会自动在验证集上跑一遍,并把指标写到runs/detect/train/下的results.csv里。用下面命令可以单独验证最优权重:
yolo detect val model=runs/detect/train/weights/best.pt data=dataset.yaml输出里重点看两个数字:mAP50和mAP50-95。mAP50 是 IoU 阈值 0.5 下的平均精度,反映框定位粗粒度上的准确性;mAP50-95 是阈值从 0.5 到 0.95 平均,对框的精细程度更敏感。人车检测场景里,如果 mAP50 高但 mAP50-95 低,说明很多框虽然命中目标但边缘不够紧,需要检查标注是否普遍外扩或内缩。
| 指标 | 含义 | 对落地的影响 |
|---|---|---|
| mAP50 | IoU≥0.5 的平均精度 | 衡量“有没有框中目标” |
| mAP50-95 | IoU 从 0.5 到 0.95 的平均 | 衡量“框得准不准” |
| Precision | 检测出的框里真目标占比 | 误检多时会下降 |
| Recall | 所有真目标里被检出的比例 | 漏检多时会下降 |
我习惯把 Precision 和 Recall 分开看。人车识别如果用于安防告警,宁可误检多也不能漏检,那就优先保 Recall;如果用于车流统计,漏检几个问题不大,但不能把路牌当成车,那就优先保 Precision。这种偏好可以通过调整推理时的conf阈值实现,不需要重新训练。建议保存 best.pt 后,用验证集多跑几组 conf 值,找到你实际使用场景的平衡点。
5. 手工标注数据的常见问题与排查:翻车记录
从拿到数据集到模型稳定,我至少在两台机器上踩过下面的坑。这一章全部按“现象 → 原因 → 解决”写,对照自己的日志就能定位问题。每一条都是血泪经验,花钱买不到的那种。
5.1 图片和 XML 名对不上,训练直接报错
现象:yolo detect train跑了几分钟后报AssertionError: label ... not found,或者提示图片数量为 0。
原因:手工复制数据时,有人把 XML 改名或者图片重命名了,导致family_sedan_126.jpg对应的 XML 是family_sedan_126(1).xml。YOLO 按图片 stem 去找 labels 和 XML,找不到就中断。
解决:拿到手先跑一遍第 2 章的 diff 脚本,把所有不一致的文件名列出来。如果只是后缀大小写问题,统一用rename或 Python 脚本改成相同规则;如果确实有文件缺失,只能从原始来源补齐。不要试图通过修改 data.yaml 的label_map来跳锅,YOLO 的文件匹配机制不支持模糊对应。
5.2 难负样本不足,误检率翻倍
现象:训练完 mAP50 有 0.85,但把模型放到一段露天停车场视频里,路牌、垃圾桶都会框出“人”,远处树影也会框成车。
原因:1000 张数据里大部分是干净的目标特写,缺少“看起来像人但实际不是人”的难负样本。模型学到的是特征组合,而不是语义。人形消防栓、车尾灯这类样本如果没出现在训练集里,推理时就会乱报。
解决:最直接的办法是在训练集里加入 50~100 张没有任何目标的图片作为背景,图片路径同样放进train目录,但对应的 txt 文件是空文件。YOLO 会把它们当负样本参与 loss 计算。更进一步可以用难例挖掘,把模型在验证集上误检率最高的图片收集起来加入下一轮训练。做法是先跑一次推理,筛选出置信度在 0.3~0.6 之间、但实际没有目标的图片,人工确认后放到负样本目录。
5.3 标注框抖动,loss 降不下去
现象:训练前 20 轮 loss 一直在一个区间震荡,验证集的 mAP50 忽高忽低,换了更大模型也没有好转。
原因:同一个人在不同图片里,有的框到头部,有的框到脚踝;或者同一条街上的车,有的框进了车顶行李架,有的只框到车身主体。标注标准不一致会让模型对“person 边界”的回归目标产生歧义。
解决:先检查标注框的长宽比分布。正常行人框高宽比在 2~4,车辆框的高宽比通常在 0.5~1.5。如果出现大量高宽比 10 以上的细长框,大概率是标注时把远处的电线杆也标进去了。统一标准后,用脚本把这些离群样本过滤掉,重新训练一般会稳定。另外,如果发现同一个人左右脚位置差很多,说明标注员习惯不同,需要定一个规则,比如“框住最外缘,不包含阴影和倒影”。
5.4 类别不平衡:车比人多怎么办
现象:验证结果里 car 的 AP 是 0.9,person 只有 0.5,甚至 person 的召回率低于 0.3。
原因:数据集中车辆框数量远多于行人框,比如车 900 个、人 500 个,模型天然倾向于学习数量多的类别,而且容易把行人这类小目标当成背景。
解决:先看统计,如果 car 和 person 的标注数差距超过 2 倍,优先做数据增强的“复制粘贴”——把 person 的目标从原图裁剪出来,随机贴到其他图像的合理位置(比如马路、人行道),生成新图片和 XML。这种方法比直接重采样更好,因为不丢失背景上下文。另一种做法是在训练时开启cls损失加权,但 YOLOv8 的公开参数里没有直接命令行开关,所以更建议用样本复制或类别感知采样。你可以写个脚本把 person 框随机缩放 0.8~1.2 倍后贴到新图上,同时生成新的 XML。
5.5 用 LabelImg 二次检查的快捷键清单
现象:训练效果不理想,想人工检查标注质量,但一张张点开看效率太低。
原因:LabelImg 虽然简单,但如果不熟快捷键,每次修改标注要鼠标点很久,检查 1000 张图不现实。
解决:记住这一套快捷键。打开 LabelImg 后按Ctrl + O选中图片目录,按Ctrl + Shift + S切换自动保存模式;切换上一张/下一张用A和D,W是画框,Ctrl + S保存当前标注。重点检查三处:框是否贴合目标轮廓、遮挡目标是否误标成完整框、difficult是否误置为 1。我一般每天检查 200 张,一周内能把整份数据过一遍。检查过程中发现与 XML 不一致的,直接用 LabelImg 修正并保存,比写脚本改 bndbox 更不容易出错。
6. 让 1000 张数据发挥十倍效果:迁移学习与数据增强的实战顺序
小数据集的正确玩法不是从零训练,而是站在预训练权重肩膀上做迁移。用 YOLOv8 自带的 COCO 预训练模型,把 backbone 冻结住先学检测头,再解冻微调,能大幅降低对数据量的需求。具体命令我一般分两步:
# 第一步:冻结 backbone,只训练检测头 yolo detect train data=dataset.yaml model=yolov8n.pt \ freeze=10 epochs=50 imgsz=640 batch=16 # 第二步:解冻全部层,用小学习率精调 yolo detect train data=dataset.yaml model=runs/detect/train/weights/last.pt \ lr0=0.0005 epochs=30 imgsz=640 batch=16freeze=10的意思是把模型前 10 层(backbone 部分)冻结,反向传播时不会更新这些层的权重。COCO 预训练权重对通用特征已经有很强的提取能力,我们只需要让它适应“人 + 车”这种特定组合。第二步学习率降到0.0005,避免在解冻后破坏已经学好的底层特征。
数据增强的顺序也有讲究。小数据集最怕的是增强过度导致训练集和验证集不再同分布。我的习惯是先不开 Mosaic,只用 YOLO 默认的翻转、HSV 扰动和缩放,跑 30 轮看验证集 loss;如果验证集 loss 一直高于训练集且不下降,再逐步加mosaic=1.0和mixup=0.2。实测发现,手工标注数据的边缘质量高,Mosaic 对小目标(远处行人)的提升很明显,但如果标注框本身不贴合,Mosaic 会放大错位。
验证时不要只盯着 mAP。下载一份包含雨雾、夜间、逆光场景的测试图,单独跑一遍预测并打印置信度:
yolo detect predict model=runs/detect/train/weights/best.pt \ source=hard_test_images/ conf=0.3 save=True看输出图里有没有把路灯杆认成人、把车身上的反光当成车灯,这类误检在 mAP 指标上反映不明显,但直接影响落地可用性。我这次用的是手工标注数据,夜间误检比自动标注数据少了近一半,就是因为在难例划分时按真实场景保留了那些“不明显”的样本。
之前有一版自动标注数据,训练时 mAP50 看着不错,一上夜间实拍就满屏乱框。后来改成手工标注数据,同样参数跑了一遍,误检率掉到原来的三分之一。从那以后,我每次拿到新数据集都会强制走一遍“解析 XML → 统计分布 → 转 YOLO → 校验文件名 → 小模型试跑”这五步,第二天再正式训练。这套流程看似繁琐,却能省掉后面十倍的调参时间。希望帮到你。
本文还有配套的精品资源,点击获取