婴儿车检测数据集VOC+YOLO格式实战:YOLOv5训练与调优指南
简介:一套面向目标检测任务的高质量数据集,包含1073张真实场景图片,涵盖婴儿车、行人、自行车、行李箱、轮椅等5个类别,共1606个矩形框标注,适合用于商场、机场、医院、车站等公共场所的婴儿车检测与行人辅助识别研究。压缩包中共2000个文件,以jpg图像、xml标注文件与txt标签文件为主,整体大小约58.43MB,同时提供VOC与YOLO两种主流格式,可直接接入YOLOv5、Faster R-CNN、SSD等常见检测框架,省去格式转换环节。所有标注由LabelImg工具人工绘制,框选准确、类别规范,数据集只包含可靠标注;其中婴儿车类别框数最多(1169框),适合作为专项检测或迁移学习的关键数据,也可联合其他公开数据扩充训练集。目前已有263人学习/下载,附有使用前必读文档,便于快速了解目录结构与加载方式,上手门槛较低,可有效降低自行标注的时间成本。
1. 婴儿车检测数据集VOC+YOLO:先搞清楚你拿到的到底是什么
做母婴智能硬件的同行,大概都经历过跑遍全网找“婴儿车”数据集的尴尬:通用数据集里婴儿车要么被归到“椅子”,要么干脆没有,想自己标,1073张图从采集到框完够忙大半个月。所以当“婴儿车检测数据集VOC+YOLO格式1073张5类别.7z”这个压缩包出现时,第一反应都是先解压看看里面到底有什么。这个数据集的卖点很直白——图片数量、类别数、标注格式全写在标题里,VOC和YOLO两套标注同时给到,意味着你既可以用LabelImg打开看框,也可以直接喂给YOLO做训练。它不追求大样本,追求的是开箱即用。我下面不评价数据质量,只说怎么把它用好:目录怎么组织、训练参数怎么配、哪些坑值得提前知道、以及后续要扩容时怎么保持两种标注同步。
2. 数据集格式拆解:VOC和YOLO标注如何互相转换、目录怎么用
2.1 VOC和YOLO格式到底差在哪:目录结构与标注对比
标题里既然写了“VOC+YOLO格式”,那解压后你一定会看到两种标注。常见的组织方式有两种:一种是分两套目录,比如VOC2007/和yolo/各自放一套;另一种是同一个images目录,Annotations/放XML,labels/放TXT。先判断你拿到的是哪种,别一上来就搜路径。如果你在Linux下解压,记住用7z x保留目录结构,而不是7z e,后者会把所有文件压平到一个目录里,XML和TXT的同名关系虽然还在,但images与标签的层级全乱了,后面写data.yaml会很痛苦。
VOC格式是Pascal VOC标准:每张图片对应一个XML,根节点里有<size>存图片宽高,<object>列表里每个对象带<name>和<bndbox>,bndbox里是xmin、ymin、xmax、ymax四个绝对像素坐标。YOLO格式是后来为深度学习训练设计的:每张图片对应一个TXT,每一行是class_id x_center y_center width height,全部归一化到0到1之间。注意YOLO的坐标是中心点加宽高,不是左上角加右下角,这一点很多人第一次转格式时容易把x_center写成xmin/w。
为什么同时给两份?因为工具链不同。LabelImg之类的标注工具原生保存为VOC XML,而Ultralytics的YOLO训练流程只认TXT。数据集作者把两套都放进来,就是让你“看框用XML,训练用TXT”,不用自己写转换脚本。但代价是两份标注容易不一致:比如你之后用LabelImg补标了一张图,只存了XML,TXT还是旧的,模型就会学到错误标签。所以拿到数据的第一步不是开训练,而是先校验两套标注是否对齐。
2.2 用脚本检查两份标注是否对齐,并完成数据划分
我一般会写一个小脚本遍历所有图片同名文件,把XML里的绝对坐标转成归一化中心坐标,再和TXT逐行比对。先放核心转换函数:
def voc2yolo(w, h, xmin, ymin, xmax, ymax): x_center = ((xmin + xmax) / 2) / w y_center = ((ymin + ymax) / 2) / h box_w = (xmax - xmin) / w box_h = (ymax - ymin) / h return x_center, y_center, box_w, box_h注意这里w和h是从XML的size节点读出来的整数,如果图片被缩放过,但XML里的size没更新,转出来的坐标就会偏移,后面训练时YOLO内部再按实际图片尺寸还原,框就会错位。所以先用实际图片尺寸替换size节点再转换。
然后是对比脚本:
import xml.etree.ElementTree as ET def load_voc(xml_path): tree = ET.parse(xml_path) root = tree.getroot() size = root.find('size') w = int(size.find('width').text) h = int(size.find('height').text) objects = [] for obj in root.findall('object'): name = obj.find('name').text bnd = obj.find('bndbox') xmin = int(float(bnd.find('xmin').text)) ymin = int(float(bnd.find('ymin').text)) xmax = int(float(bnd.find('xmax').text)) ymax = int(float(bnd.find('ymax').text)) objects.append((name, xmin, ymin, xmax, ymax)) return w, h, objects def load_yolo(txt_path): boxes = [] with open(txt_path, 'r') as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cls, xc, yc, bw, bh = int(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) boxes.append((cls, xc, yc, bw, bh)) return boxes由于VOC里类别名是字符串而YOLO里是整数,对比前必须先建立一个类别名到ID的映射,比如{'stroller': 0, 'person': 1, ...}。这里的具体类别名以你解压后的XML里的<name>为准,我习惯先跑一个统计脚本把所有name枚举出来,再去定义映射。接着比较:
def compare(xml_path, txt_path, thresh=0.01): w, h, voc_boxes = load_voc(xml_path) yolo_boxes = load_yolo(txt_path) if len(voc_boxes) != len(yolo_boxes): return False, f"box数量不一致: VOC={len(voc_boxes)} YOLO={len(yolo_boxes)}" for (name, xmin, ymin, xmax, ymax), (cls, xc, yc, bw, bh) in zip(voc_boxes, yolo_boxes): v_xc, v_yc, v_bw, v_bh = voc2yolo(w, h, xmin, ymin, xmax, ymax) if abs(v_xc - xc) > thresh or abs(v_yc - yc) > thresh: return False, f"中心点偏移过大: {name} {v_xc},{v_yc} vs {xc},{yc}" if abs(v_bw - bw) > thresh or abs(v_bh - bh) > thresh: return False, f"宽高偏移过大: {name} {v_bw},{v_bh} vs {bw},{bh}" return True, "OK"thresh我一般取0.01,也就是归一化后最多1%的像素误差。如果误报太多,先看是不是XML里size和实际图片尺寸不符。跑完对比后,再处理一下数据划分。很多YOLO仓库支持按目录划分,所以我通常会建四个目录:images/train、images/val、labels/train、labels/val,把图片和标签按9:1或8:2随机分进去。如果数据集已经提供了train.txt和val.txt,那就直接照着划分;如果没有,可以用下面这段:
import os import random random.seed(42) img_dir = 'images' # 改成你的实际目录 train_ratio = 0.9 imgs = [f for f in os.listdir(img_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] random.shuffle(imgs) split_idx = int(len(imgs) * train_ratio) train_imgs = imgs[:split_idx] val_imgs = imgs[split_idx:] with open('train.txt', 'w') as f: for img in train_imgs: f.write(os.path.join(os.path.abspath(img_dir), img) + '\n') with open('val.txt', 'w') as f: for img in val_imgs: f.write(os.path.join(os.path.abspath(img_dir), img) + '\n')注意这里的train.txt/val.txt只是图片列表,YOLOv5会默认把图片路径中images替换成labels来找TXT。如果你不按这个约定,就得在data.yaml里显式设置train_labels之类,反而麻烦。我建议直接按YOLO约定目录组织,后面训练配置省心。
3. 用YOLOv5训练婴儿车检测模型:从解压到跑通的最小命令
3.1 一分钟搭好YOLOv5环境
环境搭建其实不复杂,但有不少人卡在torch和CUDA版本上。我一般用conda创建干净环境,再单独装PyTorch,不直接用requirements里带的那一版,因为它的torch依赖经常跟已有环境冲突。步骤如下:
conda create -n yolo python=3.9 -y conda activate yolo git clone https://github.com/ultralytics/yolov5.git cd yolov5 pip install -r requirements.txt如果网络较慢,pip可以加国内镜像源,比如-i https://pypi.tuna.tsinghua.edu.cn/simple。克隆完成后先验证GPU可用:
python -c "import torch; print(torch.cuda.is_available())"输出True再继续,否则后面训练会直接掉回CPU,速度慢到怀疑人生。第一次训练时YOLOv5会自动下载COCO预训练模型yolov5s.pt,也就是热搜里经常提到的“yolo预训练模型下载”。如果下载失败,就手动把pt文件放到yolov5目录下,或者干脆用--weights ''从头训练。不过只有1073张图,从头训练效果大概率不理想,还是建议把预训练权重先搞定。
3.2 把数据集写成YOLO训练配置:data.yaml与目录映射
按照上一章整理好的目录结构,data.yaml可以这样写:
# data.yaml path: /home/user/babycar_dataset # 改成数据集根目录绝对路径 train: images/train val: images/val nc: 5 names: ['class0', 'class1', 'class2', 'class3', 'class4']关键是names列表的顺序必须和labels/txt里的class_id一一对应。很多人在这里翻车:看到VOC XML里的类别名是“stroller”“baby”,就直接按字母序写names,但YOLO标注生成时可能用的是另外一套顺序。正确做法是先解压数据集,然后随机打开一个labels目录下的txt文件,看第一列的整数范围是不是0到4;再打开对应的XML,看<name>文本,手动建立一个映射表。你也可以用脚本提取XML里的所有类别名,按出现的顺序写进names,但注意训练脚本内部不会帮你重排TXT里的class_id,所以names顺序必须完全对齐。
如果你拿到的数据是VOC和YOLO两套标注分开存放,建议训练前只依赖其中一套:我一般直接从labels目录读取TXT,不再管XML。这样至少保证训练时用的标签和验证时用的一致。不要一会儿用XML转的TXT,一会儿用原始TXT,很容易乱。
3.3 训练命令与关键参数调整
训练命令很简单,核心就一行:
python train.py --data data/babycar.yaml --weights yolov5s.pt --batch-size 16 --epochs 100 --img 640跑起来后,你会看到每个epoch打印三行损失,分别对应box loss、cls loss、obj loss。这就是“yolo损失函数”的日常监控入口。box loss用CIoU衡量框的位置和大小误差,cls loss用BCEWithLogits衡量类别错分,obj loss衡量是否存在目标。三个值加起来就是总loss。观察它们的下降趋势比只看总loss更重要:如果box loss降但obj loss不动,说明很多框虽然能框住物体,但模型对“这里有没有目标”没信心,常见于背景干扰大、标注框有遗漏的情况。
参数怎么调?我列了几个最值得动的:
| 参数 | 作用 | 建议 |
|---|---|---|
| --batch-size | 单次喂入GPU的图片数 | 8G显存用16,显存不足用8或4,但过小容易bn崩溃 |
| --img | 输入分辨率 | 640起步,小目标多可试896,但显存和时间代价翻倍 |
| --epochs | 训练轮数 | 100起步,看val loss平台期再决定是否延长或早停 |
| --lr0 | 初始学习率 | 默认0.01,小数据集降到0.005或0.001更稳 |
| --patience | 早停耐心轮数 | 默认100,如果显存不够想早停可以设50 |
| --cache | 是否缓存数据到内存/硬盘 | 数据只有一千多张,建议--cache ram提速 |
举个例子,如果你的GPU只有6G显存,batch-size设16可能直接OOM,我一般降到8并把img降到512。小数据集不建议把batch设太低,因为BN在batch太小时统计不稳定,容易复现“yolo训练中bn崩溃”——后面避坑章节会专门讲。
另外要留意,训练过程中YOLOv5会生成runs/train/exp目录,里面存了每个epoch的权重和训练曲线。训练结束后取best.pt或last.pt即可,不用纠结哪个,best.pt是验证集mAP最高的轮次,一般用它做后续验证和部署。
4. 婴儿车检测训练避坑清单:5个常见问题与排查
4.1 标注和压缩包层面的三个坑:7z解压、XML与TXT失同步
第一个坑出现在解压阶段。很多人拿到.7z文件直接用Windows右键解压,结果打开后发现Annotations和labels目录都不见了,只有一堆图片散落在某个深层目录。这通常是因为压缩包里顶层目录嵌套太深,解压工具没有保留完整路径,或者文件名里包含特殊字符。我的习惯是在Linux上用命令解压,7z x会保留目录结构,7z e则会压平目录。如果你必须用图形界面,也建议安装7-Zip而不是用系统自带的带压缩文件夹功能。
坑的实质是解压后的目录层级变了,而不是文件损坏。所以现象是“解压后XML和TXT数量对不上”,原因大概率是某个子目录没有被解压出来。解决方法是解压后先统计一下:
find . -name "*.xml" | wc -l find . -name "*.txt" -path "*/labels/*" | wc -l两个数字应该一致,如果差了,就要检查是不是有XML文件没有对应TXT,还是相反。跑一遍前面的compare脚本也能定位到具体文件。
第二个坑是VOC标注和YOLO标注存在偏移。常见现象是训练后验证集mAP不低,但打印出来的预测框总比ground truth偏大或偏小一圈。原因是有人在转换标注时把width / w写成width / h,或者把中心坐标和左上角坐标搞混了。我见过最离谱的一次,是有人把xmin和ymin当成归一化坐标直接写进TXT,导致所有框都挤在图片左上角。解决方法是不要信任转换工具,拿起两个文件手动核对一个框:用LabelImg打开图,看XML里的坐标,再通过换算验证TXT里的值,至少抽10张图检查。
第三个坑是类别id对不上。现象是训练启动正常,但到了验证时某些类别AP恒为0。原因往往是XML里的类别名和TXT里的class_id映射错了,比如stroller在XML里出现,但TXT中它的id写成了5,而nc=5意味着id只能0到4。解决方法是写一个统计脚本,读取所有TXT中的class_id最大值,再读取所有XML中的name集合,两边交叉验证。
4.2 训练和评估层面的两个坑:bn崩溃、混淆矩阵不明白
第四个坑是“yolo训练中bn崩溃”。现象很直接:训练到某一步,loss突然变成nan,显卡显存占用也掉下来,训练中断。常见原因是BatchNorm在小batch下统计量不稳定,加上初始学习率太高,梯度爆炸;另外如果数据里有全黑的图片或全白的图片,底层卷积输出方差过大也可能诱发。解决方法是分三步走:先把lr0降到0.001,再把batch-size提高到16或32,如果还崩,就修改模型的BN momentum,默认0.03改成0.01。改完之后从头重新训,不要指望从崩溃点续训,因为BN的running stats已经污染了。
第五个坑是关于“yolo混淆矩阵总合不唯一”的。评估时你会看到confusion_matrix.png,里面每一行的数字加起来可能不是100%,有些人以为代码bug。其实混淆矩阵的归一化是按行(真实框数量)来的,每一行代表一个真实类别的分布:被分对的比例、被分到其他类别的比例、被漏检成背景的比例。如果某一行总和远小于100%,说明该真实类别有很多框没有被模型任何预测框匹配到,也就是漏检严重。这比看总mAP更能暴露问题。我一般会先看每个类的对角线值,如果某个类别明显低于其他类别,就先给它补数据,而不是盲目调超参。
5. 验证与调优:用混淆矩阵和mAP判断模型是否真的可用
5.1 用val.py评估模型:mAP与损失曲线的读法
训练完不是直接拿去用,先用验证集跑一遍正式评估。YOLOv5的val.py可以单独调用:
python val.py --weights runs/train/exp/weights/best.pt --data data/babycar.yaml --task val输出里最关键的三个数字是mAP@0.5、mAP@0.5:0.95和每个类别的AP。mAP@0.5是IoU阈值为0.5时的平均AP,比较宽容;mAP@0.5:0.95则把阈值从0.5到0.95每0.05算一次再平均,对框的精确位置更敏感。如果你训练模型是为了部署到移动端做实时检测,通常只需要mAP@0.5达到0.85以上;但如果用于计数或轨迹跟踪,框的位置必须稳,我更看重mAP@0.5:0.95。当两个指标差距明显时,说明你的框“能框住但不精准”,下一步应该调bbox回归损失权重或提高输入分辨率。
除了AP,我还会看results.png里的四条曲线:train_box_loss、train_obj_loss、val_box_loss、val_obj_loss。如果val loss在训练中期掉头上升,而train loss还在降,这就是过拟合信号。对于只有1073张图的数据集,过拟合非常常见。解决方式是增加Mosaic增强、扩大--cache数据池,或者在data.yaml里加mixup: 0.5。但注意,增强太强也会让box坐标更难回归,要盯着val loss曲线做小步实验。
5.2 婴儿车检测的调优方向:小目标与遮挡
婴儿车检测具体难在哪?两种典型场景:一是摄像头架得高,婴儿车在画面里只有几十个像素,属于小目标;二是婴儿车常被大人身体或购物袋遮挡,属于密集遮挡类。针对小目标,最直接的方法是提高输入尺寸。把--img 640改成--img 896,mAP@0.5:0.95通常能涨两三个点,但训练速度和显存都要翻倍。如果算力瓶颈明显,更推荐在YOLOv5里开启P2检测层,也就是在模型neck的浅层加一个输出头,对小目标更友好。具体做法是在models/yolov5s.yaml里修改from和anchors,这个操作比较绕,新手很容易把yaml改坏,我一般是直接换用更现代的YOLOv8n-P2配置。
针对遮挡,可以用更激进的数据增强。YOLOv5默认开启了Mosaic,它把四张图拼在一起,天然产生很多截断框,有助于学习遮挡目标。但Mosaic在最后10个epoch如果还开着,可能会让模型对真实尺度分布把握不稳。我一般会在训练命令里加--hyp指定一个自定义hyps文件,把其中mosaic: 1.0保留,但把copy_paste: 0.5打开,这个属于YOLOv5较新的增强,对小样本场景提升比较明显。注意,不要同时把mixup和copy_paste都调得很高,否则标注框会被增强得太碎,反而拉低定位精度。
另外,不管之后是不是用TensorRT部署,调优阶段都要把“最终推理帧率”记在心里。婴儿车检测多半跑在边缘设备上,实时性压过一切。一个输入尺寸从640升到896的模型,在Jetson这类平台上帧率可能直接掉一半。所以我习惯先定部署平台再返回来选模型尺寸和精度目标,而不是先把模型训得又大又准最后发现部署不了。
6. 补标扩容时保持VOC与YOLO同步:一个脚本搞定
模型在真实场景里漏检了几台特定颜色的婴儿车,这时候最自然的做法是补标几十张图。问题在于:LabelImg保存的是VOC XML,而你的YOLO训练流程认的是TXT。若只把新图片塞进images/train,却忘了把对应的XML转成TXT放进labels/train,训练时会静默跳过没有标签的图片,模型学不到新知识;更隐蔽的是,如果你以前用XML转TXT的脚本手动转过一次,这次新标注的XML没有转,训练数据的标注就是新旧两套格式混着用,模型会越训越乱。
我吃过这个亏:有一次只改了XML,没同步TXT,损失曲线一直降,验证集recall却几乎不变,排错半天才发现是标签错位了。从那以后,我养成了习惯——新增或修改任何XML,都跑一遍下面这个转换脚本,把整个Annotations目录重新扫一次,覆盖生成所有TXT:
import os import glob import xml.etree.ElementTree as ET # 这个映射必须和训练时 data.yaml 的 names 顺序严格一致 names_map = { 'stroller': 0, 'baby': 1, 'person': 2, 'umbrella': 3, 'pet': 4, } def convert_annotation(xml_path, out_txt_path, names_map): tree = ET.parse(xml_path) root = tree.getroot() size = root.find('size') w = int(size.find('width').text) h = int(size.find('height').text) lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in names_map: print(f"跳过未知类别 {name},在 {xml_path}") continue cls_id = names_map[name] bnd = obj.find('bndbox') xmin = int(float(bnd.find('xmin').text)) ymin = int(float(bnd.find('ymin').text)) xmax = int(float(bnd.find('xmax').text)) ymax = int(float(bnd.find('ymax').text)) if xmax <= xmin or ymax <= ymin: print(f"框异常:{xml_path} {name} {xmin},{ymin},{xmax},{ymax}") continue xc = ((xmin + xmax) / 2) / w yc = ((ymin + ymax) / 2) / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h lines.append(f"{cls_id} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") with open(out_txt_path, 'w') as f: f.write('\n'.join(lines)) # 批量转换:假设XML在Annotations/,输出到labels/ xml_files = glob.glob('Annotations/*.xml') for xml_file in xml_files: stem = os.path.splitext(os.path.basename(xml_file))[0] out_txt = os.path.join('labels', stem + '.txt') convert_annotation(xml_file, out_txt, names_map) print(f"已生成 {out_txt}")这个脚本的关键是每次都全量重写TXT,而不是只处理新增文件。全量重写能避免旧标签残留——比如你用LabelImg删掉了一个框,但旧TXT里那行还在,训练时模型就会学到一个不存在的目标。脚本里还做了两件事:未知类别直接跳过并打印警告,这样你一眼就能发现XML里有没有新类别没加进names_map;框坐标异常(xmax <= xmin)也直接跳过,避免生成负数宽高的标签。
跑完转换后,我还会顺手把第2章的compare脚本再跑一遍,确保XML和TXT完全一致。这个动作很简单,但能省下后面几天排查标注问题的精力。补标一次、转换一次、校验一次,这个流程重复几次之后,你会觉得所谓“标注维护”也就是顺手的事。希望帮到你。
本文还有配套的精品资源,点击获取