YAOTU INSIGHTS

自建2000张拖拉机数据集:从VOC转YOLO到目标检测模型训练实战

自建2000张拖拉机数据集:从VOC转YOLO到目标检测模型训练实战
前段时间在做一个农机目标检测相关的项目需要训练一个能稳定识别田间拖拉机的视觉模型。翻了几个主流数据集平台后发现通用目标检测数据集里几乎没有专门的拖拉机类别就算有那么几张也都是零散出现在街景或农业展会照片里根本撑不起一个像样的训练集。与其到处凑不如自己整理一份于是就有了这份2000张、同时输出VOC和YOLO两种标注格式的拖拉机数据集。这篇文章不打算只丢个下载链接我想把这份数据集背后真正值钱的东西写出来2000张是怎么定下来的、类别和场景按什么口径标、VOC和YOLO之间转换的公式与校验方法、实际拿去训练的效果和翻车记录以及想自己攒农机数据集的人该避哪些坑。无论你是刚入门的目标检测新手还是已经在做农业机械视觉落地的工程师应该都能从中找到能直接用的东西。1. 农机检测为什么总被数据卡住一份2000张拖拉机数据集的由来1.1 通用数据集撑不起农业场景的真实原因先聊点实际的。很多人一开始都会拿公开的通用数据集来训模型觉得“拖拉机也算一种车应该能识别吧”。真跑起来就发现完全不是那么回事。拖拉机这种农业机械在通用数据集里属于极冷门类别样本量少不说照片大多集中在展会、公路运输等场景和实际作业环境差得太远。农业场景的特殊性主要体现在三方面。首先是外观差异大轮式拖拉机、履带式拖拉机、带驾驶室和不带驾驶室的、不同品牌不同涂装甚至同一台车挂上不同农具后整体轮廓都会变。其次是拍摄视角多样田间地头大部分时候是平视但无人机植保场景里是俯视监控杆位是大角度斜视同一个目标在不同视角下特征差异非常明显。第三是环境干扰极强泥点、灰尘、逆光、作物遮挡、多台机器混在一起通用数据集里那种“干净背景完整目标”的设定在农田里根本不存在。所以结论很直接做农机检测通用模型只能当基线真正能用的模型必须靠垂直场景数据来训练。1.2 2000张这个规模是怎么算出来的很多朋友问我为什么是2000张不是500张也不是5000张这里有一个工作量和模型收益的平衡问题。单类别目标检测的数据需求量并不像多类别那么大。一张图里如果平均有1到2个目标2000张就意味着大约3000到4000个标注框这个量级足够让模型学到比较稳定的特征表达尤其是配合在线数据增强旋转、缩放、色彩抖动、Mosaic基本能满足YOLO系列模型在小数据集上的训练门槛。再少比如只有几百张模型很容易过拟合换个场景就掉点再往上加比如做到5000张甚至更多精度确实还会涨但标注成本会直线上升。我算过一笔账。一张带有复杂背景的农机图像如果里面有遮挡、多目标或者目标很小熟练的标注员标一张大约需要2到4分钟。2000张图像单人标注按每天专注标图5到6小时计算大概需要一周半左右。如果分成两三个人同时标三到四天就能完成。这正好是一个单人项目或者小团队项目能够接受的时间成本。所以2000张并不是拍脑袋定的而是在“够用”和“做得完”之间取的一个务实值。2. 数据集内容拆解类别口径、场景覆盖与标注质量2.1 类别定义什么算拖拉机什么不算数据集的第一件事就是把“拖拉机”的定义钉死否则标注的人和你自己都会在后期被模糊类别折磨。我这份数据集的类别主体是tractor含义是拖拉机整机包含车头以及挂接的拖斗、挂车和农具。也就是说只要画面里出现了一台拖着拖斗的拖拉机边界框要把整台车的轮廓框进去而不是只框车头。这个口径好处是模型学到的是“完整农机单元”的特征在实际检测里不会只识别出半个车身导致落空。缺点是该框就比较大如果画面里还有行人或小型车辆重叠区域会带来一点干扰但在单类别数据集里这属于可接受的取舍。另一个需要定清楚的规则是画面里如果同时出现了拖拉机和其他农机比如收割机、挖掘机、装载机这些不算tractor的机械一律不标。它们会被当作背景负样本反而有助于模型在推理时减少误检。这一点很多人在标注时会犹豫看到挖掘机履带和拖拉机有点像顺手就标了结果类别边界被污染模型训练时反复震荡。2.2 场景覆盖怎么分配才不容易过拟合只把类别口径定清楚还不够场景分布才是决定模型泛化能力的核心。我构建这2000张图像时对场景做了比较刻意的分配大致比例可以给大家参考农田作业场景犁地、播种、收割、运输等约45%。这是最主要的使用场景图像以田间地头为主背景以作物、泥土、天空为主。道路行驶与转场场景约25%。拖拉机在乡道、田埂、水泥路上行驶背景里有树木、电线杆、房屋等光照条件更复杂。停放、维修与装载场景约15%。包括农机站、场院、仓库门口等目标有时会被其他车辆或杂物部分遮挡。无人机视角与俯拍场景约15%。单独列出来是因为俯视角度下拖拉机的特征和侧视差别非常大如果全部用平视图训练无人机场景基本会废。光照方面也有意识地加入了清晨逆光、正午强光、黄昏暗光以及少量夜间灯光下的图像。实际项目里你会发现农机检测漏检的高发时段往往不是夜间而是黄昏和清晨这种低对比度、逆光的环境。2.3 标注口径遮挡、小目标和边界框的约定标注口径的一致性比标注数量更重要。我在标注规范里写死了三条规则第一边界框要紧贴目标轮廓。既不要把大块背景框进来也不要切掉车轮、排气筒、拖斗边缘这类关键部件。框松了模型学到的特征带背景噪声框紧了训练时目标边缘被截断也会影响精度。需要反复核对。第二遮挡目标的处理办法。目标被遮挡面积在30%以内时正常标注目标主体仍然可辨认遮挡面积达到30%到70%时如果还能根据露出部分明确判断是拖拉机就标否则不标遮挡超过70%的直接丢弃。很多刚开始做标注的人习惯把遮挡目标跳过造成数据集中“容易被挡住”的场景缺失模型在真实遮挡环境里表现就会变差。第三极小目标问题。在无人机俯拍场景和远距离视野里目标有可能只有几十个像素。我当时的约定是目标在图像中占比小于约2%并且边长小于32像素的可以舍弃不标。原因是在640分辨率训练下这类小目标对loss的贡献非常不稳定反而会拉低整体收敛速度。如果业务确实需要检测极小目标建议单独建一个小目标数据集配合切图策略来处理而不是混在常规数据集里。3. VOC转YOLO格式坐标公式、转换脚本与校验方法3.1 先从VOC的XML结构说起标题里同时写了VOC和YOLO两种格式是因为实际工作中确实存在两种数据使用场景。VOC格式是老牌标注格式很多早期框架和自有训练流程都默认读它YOLO格式则是Ultralytics系列等主流训练框架直接使用的格式一个类别ID加四个归一化坐标非常紧凑。VOC格式的标注文件是一个XML核心字段大概长这样annotation folderJPEGImages/folder filenameimg_0001.jpg/filename size width1280/width height720/height depth3/depth /size object nametractor/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin182/xmin ymin240/ymin xmax760/xmax ymax520/ymax /bndbox /object /annotation这里的bndbox存的是像素级的绝对坐标也就是左上角和右下角的横纵坐标值。读取逻辑非常简单Python里用xml.etree.ElementTree就能把每个object和对应的bndbox解析出来。这也是VOC格式最让人省心的地方一眼就能看懂出问题时好排查。3.2 归一化坐标转换公式与实用脚本YOLO格式和VOC格式最大的区别在于坐标空间。YOLO标注文件里每行对应一个目标格式是class_id x_center y_center width height其中四个数值全部是相对于图像宽高的归一化比例取值在0到1之间。从VOC的bndbox转YOLO坐标核心公式就四条x_center ((xmin xmax) / 2) / image_width y_center ((ymin ymax) / 2) / image_height width (xmax - xmin) / image_width height (ymax - ymin) / image_height注意点有两个一是VOC的绝对像素坐标转出来后x_center和y_center都是浮点小数不是整数二是YOLO格式没有“类别名”只有一个整数类别ID所以类别名到ID的映射表必须单独维护一般在数据集的data.yaml或者classes.txt里写明顺序一旦变了训练出来的模型推理结果就是乱的。下面是我整理数据集时实际用的转换脚本可以直接跑import xml.etree.ElementTree as ET import os from pathlib import Path def voc_to_yolo(xml_path, out_path, class_map): 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_map: continue class_id class_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) 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 lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}) with open(out_path, w) as f: f.write(\n.join(lines)) if __name__ __main__: # 类别映射表顺序不能随便改 class_map {tractor: 0} xml_dir Path(Annotations) txt_dir Path(labels) txt_dir.mkdir(exist_okTrue) for xml_file in xml_dir.glob(*.xml): out_name xml_file.stem .txt voc_to_yolo(xml_file, txt_dir / out_name, class_map)脚本本身不复杂但有几个细节值得留意。比如浮点数保留位数保留6位足够了再多没有意义再比如脚本里对类别名做了一次过滤遇到映射表里没有的类别自动跳过避免把自己没定义好的脏数据带进去。3.3 转换后必做的三条校验格式转换最容易在“看起来转了但实际有问题”的状态下被忽略。我给自己定了一个流程每次转换后必须做三件事第一反向校验。写一个yolo_to_voc的逆向函数把所有生成的标注文件再转回XML并与原始XML的bndbox值对比偏差超过0.5像素就说明转换过程有bug。这比肉眼检查靠谱得多。第二可视化叠加。在图像上画出转换后的归一化坐标框存成新图片随机抽个几十张快速浏览。重点看两类问题框有没有跑偏、有没有裁切到图像边界。如果你的标注文件里出现了某个坐标加宽度大于1的情况说明原始VOC里的框越界了需要在转换脚本里加一个clip操作把坐标强制限制在图像范围内。第三类别编号核对表。写死一个class_map拖到训练配置里然后打印出来人工核对一遍。tractor对应0这个顺序必须和训练配置文件完全一致。之前有一次我改了类别顺序忘了同步训练出来之后所有预测框全部错位排查了整整半天才发现是这种低级问题。3.4 目录结构一次整明白数据集目录结构建议一开始就按训练框架的习惯建好免得后面来回折腾。我这里给出一个既兼容VOC习惯、又能让YOLO训练直接读取的结构tractor_dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ ├── Annotations/ │ ├── train/ │ ├── val/ │ └── test/ └── data.yaml其中images和labels用于YOLO训练Annotations保留VOC格式的原始XML以备后续转别的格式。images和Annotations一一对应labels由Annotations转换而来。data.yaml里面写明路径、类别数量和类别名称放在数据集根目录训练的时候直接引用。有些开源数据集会把train/val/test的划分信息单独放在一个txt里比如VOC风格的ImageSets/Main/train.txt。这也没问题但要注意划分之后images、labels、Annotations三个目录里的子目录也要同步划分否则训练时很容易出现“图在train里标注在val里”的尴尬局面。4. 拿这份数据集跑训练参数配置、精度表现与翻车记录4.1 模型和训练参数怎么选数据准备好了接下来是训练环节。我挑选了YOLO系列里的两个常用小模型进行对比测试其中一个约700多万参数的轻量版另一个稍大一些。这里不点名具体版本了直接说选型逻辑。对于2000张规模的单类别数据集选模型时有两条路一是直接上大模型期望凭容量吃下所有特征但2000张数据喂给大模型大概率过拟合还没有足够数据支撑它的复杂结构二是用小模型加合理的数据增强先把基线跑通再逐步加容量。我更推荐后者的思路。实际选了约700万参数的版本因为它在边缘设备上也有部署价值训练速度快方便反复试错。训练参数可以参考这份输入分辨率imgsz640。拖拉机目标在画面里通常属于中等偏大的目标640够了不用一上来就上1280。批大小batch16。取决于显存大小8G显存跑这个配置比较稳。训练轮数epochs120。2000张数据配这个轮数能够充分收敛同时配合早停机制防止过拟合。数据划分train/val/test约等于1600/200/200。在线增强开启Mosaic、随机透视、色彩抖动、翻转等常见增强手段让模型在有限数据里看到更多“虚拟样本”。4.2 实测结果mAP能到多少卡点在哪先说结论在测试集200张图上mAP50大约在0.85到0.93之间波动mAP50-95在0.55到0.65之间。具体数值和训练时的随机种子、数据增强组合都有关系不同场景分布下会有明显差异。如果只看精度数字这个结果对单类别农机检测来说已经处于可用状态但实际跑起来问题还是在细节里。我在这轮训练里遇到三个比较典型的卡点第一个是小目标漏检。无人机俯拍场景里拖拉机在画面中占比很小模型经常直接漏掉。后来我把俯拍图单独切块切成640x640的小图重新标注精度立刻提了一截。切图和直接缩图是两回事切图能保留目标原始尺寸对模型学习小目标帮助很大。第二个是强逆光漏检。傍晚逆光环境下拖拉机车身陷入大面积阴影模型会把目标和背景混在一起。这类问题单靠数据很难根治我的做法是在数据增强里加重了亮度扰动和噪声模拟同时收集了一些真实逆光样本补充进数据集。第三个是工程机械的误检。挖掘机、装载机的轮廓线条和拖拉机有相似之处模型会把它们当成拖拉机。这个问题的根源在于类别定义不够“挑剔”。我重新清洗了一轮标注把容易混淆的工程机械图像全部保留为背景但不标注再把它们按一定比例混入训练集让模型看到足够多的“反例”误检率才明显降下来。4.3 不同格式在训练框架里的使用体验对比表格方式总结一下VOC和YOLO两种格式在实际使用中的差别方便你在自己的项目里做取舍对比项VOC格式YOLO格式存储形式XML文件信息全txt文件紧凑坐标表示像素绝对坐标归一化浮点坐标人类可读性高容易用文本工具排查低不直观框架兼容性老框架、自有数据管线常要求新一代YOLO训练框架原生支持转换成本需要解析XML需要维护类别映射表出错风险文件结构复杂、字段容易漏类别ID错位、坐标越界我的实际感受是VOC格式适合做“数据源头”适合反复修改、检查也适合将来转成COCO等更多格式YOLO格式适合做“训练入口”直接喂框架速度快、占用小。所以理想状态就是像这份数据集一样两种格式同时维护用脚本保持同步而不是只留一种。5. 自建农机数据集的避坑清单与扩充思路5.1 标注工具选型与效率提升标注工具我用过好几款最推荐的是LabelImg这类老牌工具界面简单保存为VOC格式的XML是原生的快捷键操作非常顺手。唯一问题是它对大图和长尾场景的支持一般如果你有大量无人机俯拍的大分辨率图处理起来会有一些卡顿。批量处理时的效率细节很关键。比如文件命名尽量用统一前缀加编号像tractor_0001.jpg、tractor_0002.jpg避免中文名和特殊字符标注完一张保存一张不要攒到晚上统一输出多台机器标注时每次开工前同步一次类别定义文件和命名规范。这些听起来都是小事但真正影响数据质量的往往是这些小事。5.2 自动化清洗标注的小脚本标注完成之后我强烈建议写一个小脚本对所有标注文件做一遍自动化体检。检查项至少包括XML能否正常解析、检测框坐标是否为数值且大于零、框是否超出图像边界、图像文件是否存在且能正常打开、类别名是否都在预定义集合里。import xml.etree.ElementTree as ET from pathlib import Path def check_xml(xml_path, img_dir): tree ET.parse(xml_path) root tree.getroot() filename root.find(filename).text img_path img_dir / filename if not img_path.exists(): return [fmissing image: {filename}] w int(root.find(size/width).text) h int(root.find(size/height).text) errors [] for obj in root.findall(object): box obj.find(bndbox) xmin float(box.find(xmin).text) ymin float(box.find(ymin).text) xmax float(box.find(xmax).text) ymax float(box.find(ymax).text) if xmin xmax or ymin ymax: errors.append(finvalid box: {filename} {xmin} {ymin} {xmax} {ymax}) if xmin 0 or ymin 0 or xmax w or ymax h: errors.append(fout of bounds: {filename}) return errors这套脚本虽然简陋但能把标注数据里90%的低级问题一次性揪出来。数据质量校验应该放在训练之前不要指望训练完事之后靠loss曲线来发现问题。5.3 数据来源合规与后续扩充方向最后是数据来源的问题。自建数据集时必须注意版权合规性。自己拍摄的田间图像最稳妥如果是网络公开图片要确认来源的授权范围商业项目里建议全部使用自采数据或已明确授权开放的数据。从扩充角度看这份2000张数据集只是一个起点。后续可以考虑的方向有加入更多种类的农机类别收割机、播种机、植保无人机等扩展成多类别检测叠加热成像或深度图做多模态融合检测把视频抽帧和半自动标注流程接起来以相对低的成本把规模从2000张扩充到5000张以上。数据规模上去之后模型可以尝试更大容量版本精度还有继续提升的空间。我在实际整理这份数据集的过程中有几点特别深的体会。第一标注质量和口径统一远比数量重要2000张只要干净效果会比5000张脏数据强很多第二VOC和YOLO两种格式都维护是非常值得的多花的那点脚本时间会在后面训练、换框架时省下大把时间第三数据集的构建不是一次性工作你训练的模型每暴露一个问题数据集就需要回流补充一批样本这个闭环整个跑通之后农机检测这个方向才算真的入门了。如果你也正在做类似的事情希望这份整理过程能帮你少走几步弯路。