ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

自建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> <folder>JPEGImages</folder> <filename>img_0001.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>tractor</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>182</xmin> <ymin>240</ymin> <xmax>760</xmax> <ymax>520</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_ok=True) 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万参数的版本,因为它在边缘设备上也有部署价值,训练速度快,方便反复试错。

训练参数可以参考这份:

  • 输入分辨率:imgsz=640。拖拉机目标在画面里通常属于中等偏大的目标,640够了,不用一上来就上1280。
  • 批大小:batch=16。取决于显存大小,8G显存跑这个配置比较稳。
  • 训练轮数:epochs=120。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 [f"missing 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(f"invalid box: {filename} {xmin} {ymin} {xmax} {ymax}") if xmin < 0 or ymin < 0 or xmax > w or ymax > h: errors.append(f"out of bounds: {filename}") return errors

这套脚本虽然简陋,但能把标注数据里90%的低级问题一次性揪出来。数据质量校验应该放在训练之前,不要指望训练完事之后靠loss曲线来发现问题。

5.3 数据来源合规与后续扩充方向

最后是数据来源的问题。自建数据集时必须注意版权合规性。自己拍摄的田间图像最稳妥;如果是网络公开图片,要确认来源的授权范围,商业项目里建议全部使用自采数据或已明确授权开放的数据。

从扩充角度看,这份2000张数据集只是一个起点。后续可以考虑的方向有:加入更多种类的农机类别(收割机、播种机、植保无人机等),扩展成多类别检测;叠加热成像或深度图,做多模态融合检测;把视频抽帧和半自动标注流程接起来,以相对低的成本把规模从2000张扩充到5000张以上。数据规模上去之后,模型可以尝试更大容量版本,精度还有继续提升的空间。

我在实际整理这份数据集的过程中有几点特别深的体会。第一,标注质量和口径统一远比数量重要,2000张只要干净,效果会比5000张脏数据强很多;第二,VOC和YOLO两种格式都维护是非常值得的,多花的那点脚本时间会在后面训练、换框架时省下大把时间;第三,数据集的构建不是一次性工作,你训练的模型每暴露一个问题,数据集就需要回流补充一批样本,这个闭环整个跑通之后,农机检测这个方向才算真的入门了。如果你也正在做类似的事情,希望这份整理过程能帮你少走几步弯路。

RELATED READING

延伸阅读

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