做果树类目标检测的人应该都有同感:真正让模型翻车的,往往不是网络结构选得不对,而是训练数据和实际场景隔着一层窗户纸。去年我参与一个果园智能化项目,核心任务就是识别树上的柑橘。翻遍公开的通用检测数据集,要么是超市货架上的鲜果特写,要么是背景干净得像摄影棚的摆拍,拉到真实果园里一测,漏检和误检多得没法看。后来我们索性自己整理了一份贴近果园场景的柑橘检测数据集——共2511张图、单类别,同时提供VOC和YOLO两种标注格式,所有目标都是挂在枝头的树上柑橘。这篇就把从数据采集、标注转换、模型训练到落地优化的完整过程复盘一遍,包括那些不亲自踩一次就很难记住的坑。
1. 树上柑橘检测为什么难:从通用数据集到果园实测的差距
1.1 光照、背景与遮挡:果园不是一个“干净”的训练环境
很多公开检测数据集里的物体,要么居中摆放、要么背景简洁,目标轮廓清楚得很。但到了柑橘园里,情况完全不一样:阳光透过树叶洒下来,果实表面既有高光也有阴影;背景几乎全是深浅不一的绿色枝叶,青果混在叶子里,颜色反差极小;还有大量树枝横在果实前面,把目标切成好几块。把这些图丢给通用目标检测模型,它经常会给出“看到了一个东西但不确定是什么”的模糊特征,最终结果就是漏检。
我一开始试过直接用COCO预训练权重去跑果园实拍视频,效果可以用惨烈来形容:晴天中午背光环境下,近处的橙色大果倒还能框住,一旦果实稍微被叶子挡住二分之一,模型就开始犹豫;更不用说逆光时整个果实暗成一团,检测框直接在树叶上乱跳。这不是模型笨,而是训练数据里根本没有这种“脏乱差”的样本分布。果园场景的难点恰恰在于,我们要检测的不是一个孤立物体,而是一个嵌在复杂背景里、部分可见、光照多变的物体。
另外,果园作业时相机和被检测目标的相对位置很不固定。无人机俯拍是一回事,地面巡检车平视是另一回事,采摘机械臂的摄像头还可能从下往上仰拍。不同角度下果实的形变、遮挡模式完全不同。如果数据集的视角太单一,模型换一个安装位置基本就废了。
1.2 尺度变化:从近景到远景,同一颗果实能差几十倍
你可能觉得检测一颗柑橘没什么大不了,但真正标注过果树的图之后会发现,尺度问题比想象中严重。同一张巡检图里,可能既有近处直径接近三百像素的大果,也有树冠深处只有十几个像素的小果。这两种目标虽然在标注上都叫“柑橘”,但在卷积网络看来,特征完全是两个量级。
小目标在目标检测里一直是老大难,因为经过几次下采样之后,小果实的特征图可能只剩几个像素点,信息几乎丢光了。这也是为什么很多从通用数据集上学来的经验在果园场景不适用:通用数据集的物体通常占图像面积的百分之十几甚至更多,而果园里的中远景目标常常只占图像的百分之一二。类别的语义很简单——都是柑橘,但尺度分布却很复杂。2511张图看起来不多,可如果尺度覆盖不广,模型学到的就只是单一距离下的“柑橘样子”,换个视角就没戏了。
1.3 为什么只需要“1类别”:把任务边界定义清楚
很多人看到“1类别”会觉得数据集太简单,其实这是刻意为之。树上柑橘检测这个任务,核心目标是把果实位置找出来,至于它是成熟还是青涩、是大果还是小果、有没有病虫害,那是后续分类或分割任务的事。把任务边界收窄到“只检测果实”,标注成本能大幅下降,模型也能把全部容量用在位置回归和遮挡鲁棒性上。
我在标注时遇到过类似问题:一颗柑橘大部分被叶子遮住,只剩一小块橙色,到底算不算目标?如果不定义清楚,标注员全凭感觉,最后模型学到的边界就会很混乱。我们在数据集里明确:只要可见的果实轮廓能被人眼辨认,且可见面积超过四分之一,就标注;被完全遮住、只有零星色块的,一律不标。这样整个数据集的标注标准是一致的,训练出来的模型也更听话。后续如果要扩展成熟度分析,完全可以在已有的检测框基础上重新抽样做二次分类,检测框的定位误差基本不会成为瓶颈。
2. 数据从哪来:2511张图的采集、筛选与质量控制
2.1 拍摄设备与角度选择:别迷信单一高端设备
做数据采集之前先明确一件事:模型终归要在实际部署设备上跑,训练数据应该尽量贴近部署端的成像效果,而不是追求某台高端相机的“完美画质”。我们当时采集用了三种设备:普通手机、运动相机和一台工业相机,分辨率和色彩风格都有差异。混合使用的好处是模型能学到对不同成像质量的鲁棒性,后期换设备部署时不用重新采集。
拍摄距离我控制在0.5米到4米之间,视角覆盖俯视、平视、仰视三种。为什么要这么干?因为果园里的果实分布很立体,采摘机器人的机械臂摄像头往往会从斜下方看果实,这时候遮挡模式跟人站在地上平视完全不一样。采集时间上,我要求覆盖早中晚三个时段,晴天阴天都拍,尽量把顺光、侧光、逆光的光照条件都收进来。当时为了拍逆光样本,我们专门挑下午太阳快落山的时候,对着西边树冠拍了一批,这些图后面在训练里起了大作用。
2.2 初筛与清洗:哪些图必须果断扔掉
原始素材量远不止2511张,最后留下来的是反复筛选的结果。筛选规则我列一下,供参考:
- 严重运动模糊的图直接删。果园里风一吹树就晃,手机手持拍摄很容易糊,这种图标注员看着都费劲,模型更学不到有效特征。
- 整张图没有果实的删。这类图可以作为背景负样本,但我们这个数据集定位是有监督检测,暂不引入纯背景图。
- 过曝和死黑太严重的删。顺光下高光溢出的果实只剩一片白色,逆光下果实黑成一团,少量保留可以提升鲁棒性,但比例太高会污染训练。
- 取景内容重复度过高的删。连续拍摄的视频抽帧经常会得到几乎一样的图,我会按结构相似度做去重,确保同一颗果树不要占据过多样本。
清洗这一步最容易被忽略,但它直接决定数据集的“信噪比”。我第一次做数据集时贪多,把模糊图全塞进去,结果训练时loss怎么都降不下去,后来查原因才意识到是脏数据干扰。从那以后,初筛阶段我都宁可少收,也不收废图。
2.3 标注规范:遮挡、粘连与工具选择
标注是数据集制作里最耗时的一环,2511张图看起来不多,但果园图平均每张有五到十二个果实,一张图就可能标上百个点。为了保证一致性,我写了一份简单的标注规范,核心就几条:
- 果实之间粘连严重但轮廓清晰可辨时,分开各标一个框,允许框与框之间重叠。
- 可见面积大于四分之一的目标才标,小于这个标准的不标。
- 同一果实被树叶分成几块时,如果人都能判断是同一颗果实,就按完整轮廓画一个框,而不是拆成多个碎片框。
- 紧贴图像边缘、被切掉一半的果实,只要剩余可见部分能辨认,也标出来。边缘目标在推理时很常见,模型必须见过。
标注工具我用过labelImg,也用过X-AnyLabeling,两者都支持VOC和YOLO格式导出。个人更推荐X-AnyLabeling,因为它在目标检测标注之外还支持辅助分割,对于人眼难以判断果实边界的场景,可以先做一步分割预标注再手动修正,效率提升不少。不过无论用哪个工具,最终保存出来的格式都需要自己做一次统一检查,因为不同工具对difficult、truncated这类附加字段的处理方式可能不一样。
3. 双格式交付:VOC与YOLO标注的转换细节与校验
3.1 VOC和YOLO格式的本质区别:坐标系的换算关系
VOC格式本质上是把每个目标的边界框用左上角点和右下角点的像素坐标存进XML文件里,单位是像素,坐标值取决于图像本身的宽高。YOLO格式则完全不同,每一行对应一个目标,内容是“类别ID、中心点x、中心点y、框宽、框高”,并且全部归一化到0到1之间。
从VOC到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
听起来很简单,但实际转换时最容易出错的点有三个:一是忘记除以图像宽高,导致坐标值大于1,训练直接报错;二是把宽高看反,XML里width/height是图像的宽高,bndbox里是框的坐标,混在一起就乱了;三是坐标值必须是浮点数,如果用整数除法,小目标归一化后直接就归成0了。我见过不少人在这上面耗了半天,其实就是个除法精度问题。
3.2 编写转换脚本:一个能跑通的参考实现
我自己的习惯是先把所有标注统一整理成VOC XML,因为XML是文本格式,便于检查,然后再写一个Python脚本批量转成YOLO txt。核心代码大概长这样:
import os import xml.etree.ElementTree as ET class_map = {"orange": 0} def voc_to_yolo(xml_file, img_width, img_height, out_txt): tree = ET.parse(xml_file) root = tree.getroot() lines = [] for obj in root.findall("object"): name = obj.find("name").text if name not in class_map: continue cls_id = class_map[name] 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.0 / img_width y_center = (ymin + ymax) / 2.0 / img_height w = (xmax - xmin) / img_width h = (ymax - ymin) / img_height x_center = max(0.0, min(1.0, x_center)) y_center = max(0.0, min(1.0, y_center)) w = max(0.0, min(1.0, w)) h = max(0.0, min(1.0, h)) lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") with open(out_txt, "w", encoding="utf-8") as f: f.write("\n".join(lines))这段脚本里我加了坐标截断,把越界的坐标拉回0到1之间。需要注意,截断只能兜底,不能替代人工修正。如果某张图大量出现坐标越界,那多半是标注框本身画错了,或者图像宽高和标注时用的图像尺寸不一致,这种情况要回到原图去查,而不是简单截断。
3.3 标注校验:规则检查加可视化抽查
转换完成之后不能直接拿去训练,必须校验。我一般分两步。第一步是规则校验:写一个脚本遍历所有txt文件,检查每行是否恰好五个字段、类别ID是否在合法范围内、框宽高是否大于0、坐标是否都在0到1之间。第二步是可视化抽查:用OpenCV把标注框画到原图上,随机抽几十张人工过一遍。这一步尤其要在转换初期做,因为如果脚本逻辑有问题,规则校验未必能发现,但画框看一眼马上就能暴露。
import cv2 image = cv2.imread("demo.jpg") h, w = image.shape[:2] with open("demo.txt", "r", encoding="utf-8") as f: for line in f: cls_id, xc, yc, bw, bh = map(float, line.split()) x1 = int((xc - bw / 2) * w) y1 = int((yc - bh / 2) * h) x2 = int((xc + bw / 2) * w) y2 = int((yc + bh / 2) * h) cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite("check.jpg", image)双格式交付的另一个好处就在这里:VOC格式便于人工检查和绘图,YOLO格式便于直接训练。数据集中两套标注文件完全对齐,用哪个框架都能无缝切换,省去了后期因为格式不匹配再返工的麻烦。
4. 用2511张图训练一个能用的检测模型:配置与踩坑
4.1 数据划分:按“树”切分,别让同株树的图同时进训练和验证
数据划分是很多人忽略的关键环节。我刚开始图省事,把所有图片随机打乱后按8比1比1切分成训练集、验证集和测试集,第一轮训练出来的mAP50相当漂亮,接近0.92,当时还挺高兴。后来一个做过农业视觉的朋友提醒我:拍摄过程中同一棵树的图像在光照、背景和果实形态上高度相似,如果随机划分,很容易出现同一棵树的图片一部分在训练集、一部分在验证集的情况,验证集指标会虚高,模型其实并没有真正泛化到没见过的树。
意识到这个问题之后,我把图片按果树编号分组,保证同一棵树的图片只出现在一个集合里。重新划分后,mAP50掉到0.84左右,但测试结果更真实。按我的经验,数据量只有2511张时,最优划分比例通常是训练集占85%到88%,验证集占8%左右,测试集占5%左右。验证集小没关系,关键是保证分布不泄漏。
4.2 data.yaml配置与训练参数选择
用YOLO系列训练时,需要准备一个data.yaml文件,内容大致如下:
train: D:/datasets/citrus/images/train val: D:/datasets/citrus/images/val test: D:/datasets/citrus/images/test nc: 1 names: 0: orange训练命令我推荐从YOLOv8s开始试。1类别的任务,网络容量需求没有想象中那么大,盲目上YOLOv8x反而容易过拟合,尤其只有两千多张图的情况下。imgsz的选择要看原图分辨率和目标尺度。如果果实小目标占比高,建议起步imgsz=1280,因为直接缩到640会把小果实的特征抹掉。显存不够的话,可以用imgsz=640先跑通,再配合后面的切片推理来补小目标。
我自己最终用的训练参数是:模型为YOLOv8s,imgsz=1280,batch=8,epochs=150,优化器用SGD。训练前加载yolov8s.pt预训练权重而不是从零开始,这样收敛速度快很多,而且预训练模型在通用视觉特征上的积累对果实边缘、纹理识别是有帮助的。
4.3 评估指标怎么看:别被mAP50带偏
训练结束后要重点看三个指标:precision、recall和mAP50。密果场景下,recall往往比precision更重要——漏检一个果实,意味着采摘时这颗果就被漏掉了;而误检几个背景,实际部署时通过后处理还能过滤。在小目标占比高的情况下,mAP50-95通常不会太高,这很正常,因为小目标对框重叠非常敏感,微小的像素偏移就会让IoU掉很多。我训练出来的模型mAP50-95大概在0.58左右,mAP50却有0.84,一开始也焦虑,后来发现把评估指标换成小目标专用的平均精度再看,趋势是健康的。
另外要留意训练日志里的loss曲线。如果训练集loss稳定下降而验证集loss震荡过大,先检查是不是某个类别的样本数太少,再检查划分有没有泄漏。2500张图做单类检测,过拟合的风险一直存在,最好的解决办法不是换更大的模型,而是先看数据分布是否合理。
5. 从检测框到果园落地:小目标、产量估算与后续扩展
5.1 高分辨率图像上的小目标漏检:切片推理的解决思路
2511张图里有一批是接近四千万像素的高分辨率果园全景图,直接把整图缩放到416或640去推理,树冠深处的小果实基本全丢。我试过从imgsz=640提升到1280,效果有改善但远不够,而且显存开销大了很多。更实用的方案是切片推理:把高分辨率原图按一定步长切成若干重叠的小块,每块分别送进模型检测,再把结果映射回原图坐标,最后用NMS合并重叠框。
切片大小一般取640或1024,重叠率设在20%左右。重叠太少会导致果实正好被切在边缘时检测不稳,重叠太多又浪费时间。这个思路当时帮我把小目标漏检率降了不少,而且完全不用改模型结构。如果嫌自己实现麻烦,可以直接用开源的切片推理工具库,核心参数也就是切片大小、重叠率和置信度阈值。
5.2 数据增强的取舍:果园场景不是越花哨越好
YOLO自带不少数据增强策略,比如随机翻转、色彩抖动、mosaic拼接。但果园场景要慎用部分增强。我试过把mosaic开到默认强度,训练出来的模型在密集小目标上表现反而变差,原因很可能是mosaic把四张小图拼在一起,果实目标被压缩到极小尺寸,等效于给模型加了很多“极小目标”的样本,但这些样本跟真实果园的成像规律并不一致。
更实用的做法是:关闭或调低mosaic,保留适度翻转和轻微角度旋转;色彩抖动要控制幅度,因为柑橘颜色和绿叶背景的区分已经有难度,过度调色会破坏这种区分。我在后期额外离线生成了一批低光照增强图,模拟清晨和黄昏的昏暗光线,把低光下的漏检问题压下去不少。数据增强不是越多越好,而是要让模型见到“部署时会见到的样子”。
5.3 从单类别检测到更多应用:产量估算与成熟度分析的两种路子
1个类别、2511张图,这个数据集的终点不是变成一个只会在测试集上刷分的模型,而是果园自动化的起点。我梳理了三条后续扩展路线,都是基于已有检测框去做的:
- 成熟度分析:在检测框内再接一个轻量分类器,或者直接用颜色直方图做阈值判断,把果实分成成熟、半熟、青果。这个方案用现有标注就能启动,不需要重新标框。
- 产量估算:先统计检测到的果实数量,再结合相机标定参数估算单株产量。更简单粗暴的做法是直接训练一个回归模型,输入检测框数量、果冠面积等特征,输出预估产量。前者可解释性强,后者精度上限更高。
- 采摘引导:检测框的中心点可以作为机械臂抓取的二维参考,配合深度相机获取距离信息,就能换算成机械臂的三维目标点。这里对框的定位精度要求比分类高,所以训练时分配给box loss的权重可以适当调高。
这三条路不需要重新采集几千张图,都是在现有数据集的检测能力之上叠加模块,这也是我坚持把一个单类检测数据集打磨干净的原因——基础打好了,上层应用才能稳。
做完这套流程,我最大的体会是:数据集的质量上限决定了模型的天花板。2511张图不算多,但采集覆盖到位、标注标准一致、划分方式严谨,训练出来的模型确实能在果园环境里跑。很多人一上来就纠结模型选哪个、超参数怎么调,其实把数据吃透了,后面都是水到渠成的事。做农业视觉项目尤其需要耐心,果园不比实验室,风、光、树影一直在变,数据集的细节里藏着的都是实战里会成倍放大的问题。