
简介这套柑橘果目标检测数据集专为农业视觉与智能农机研究而构建包含580张真实果园场景图片及对应Labelme标注文件可支撑树上挂果与树下落果的区分识别适用于果园自动化收获、成熟度监测、产量预估以及农业机器人视觉导航等任务。压缩包内共1159个文件其中579张JPG图像与580个JSON标注一一对应标注格式通用易扩展整体大小125.72MB便于下载与数据集管理。标注内容覆盖On-tree与Under-tree两类目标树上样本25319个、树下样本17334个能帮助模型学习不同光照、姿态和遮挡条件下的果实特征为算法训练提供扎实的数据基础。该资源已有438人学习适合需要高质量目标检测标注数据的研究人员与开发者直接使用。1. 先看清这个数据集在逼模型学什么On-tree与Under-tree不是同一类难题做果园视觉的工程师看到这个标题应该先想到一件事这个数据集故意没把两类果分开存放而是让 On-tree 和 Under-tree 的标注出现在同一份 labelme 文件里。这等于要求模型在同一套权重下同时处理被叶子遮挡的树上柑橘和堆在地上的树下柑橘果两者的尺度、背景和光照差异比很多跨数据集任务还要大。580 张图的数量不算多但覆盖面刚好是果园机器人最需要的两条工况树上巡检计数、树下落果识别。它适合拿来验证 YOLO 系列在果实检测上的迁移效果也适合做数据增强策略的对比实验如果你只是想跑通一个通用目标检测流程这个数据集的细节你暂时用不上等真正上了果园线再回头看会发现每一处标注都在对工况说话。2. labelme标注的黑匣子先把JSON拆开再决定转不转YOLO2.1 labelme格式到底存了什么目标检测数据集里labelme 格式看起来并不直观它默认不给你矩形框而是存多边形坐标。对于检测任务多边形似乎多余但它给了你很多“后悔药”。如果后来发现矩形框不够用多边形的点还能重新生成分割掩码、旋转框或者做果实着色的统计。我拿到这类数据集的第一动作永远是解析 JSON而不是立刻转成 YOLO 训练。labelme 的 JSON 结构大致是这样的{ version: 5.2.0.post1, flags: {}, shapes: [ { label: On-tree, points: [[168, 245], [182, 247], [199, 251]], group_id: null, shape_type: polygon, flags: {} } ], imagePath: IMG_0001.jpg, imageData: null }shapes 是标注实体的核心每个元素对应一个实例。label 是类别名points 是多边形的绝对像素坐标坐标原点在左上角y 轴向下和 OpenCV、PIL 的读取结果一致。shape_type 常见的有 polygon、rectangle、circle。circle 要特别小心它的 points 通常只有两个点一个圆心一个圆周点直接用 min/max 取外接矩形会把半径当成半条弦后面转换阶段我会单列这个坑。还有一个容易忽略的字段是 imageData。很多 labelme 版本导出时它都是 null表示图片没有内嵌到 JSON 里少数版本会塞一长串 base64。解析时不要碰 imageData直接按 imagePath 去读原图否则几百张图就能把内存撑爆。2.2 先统计分布类别比、实例密度与图片分辨率转换之前我习惯先把标注当成数据审视一遍而不是直接写转换脚本。三个问题必须回答两个类别的实例数量是否均衡、单张图片上最多有多少个框、图片分辨率大致落在什么范围。下面这个脚本只用了标准库加 PIL遍历 JSON 输出统计结果import json import os import glob from collections import Counter from PIL import Image json_list sorted(glob.glob(annotations/*.json)) class_counter Counter() density_list [] for json_path in json_list: with open(json_path, r, encodingutf-8) as f: data json.load(f) labels [shape[label] for shape in data[shapes]] class_counter.update(labels) density_list.append((os.path.basename(json_path), len(labels))) img_path os.path.join(images, data[imagePath]) if os.path.exists(img_path): w, h Image.open(img_path).size # 这里可以按图片名记录宽高用于决定训练时的 imgsz print(类别实例数:, dict(class_counter)) print(最密集的10张图:) for name, cnt in sorted(density_list, keylambda x: -x[1])[:10]: print(f {name}: {cnt}个实例)这个脚本回答两件事。第一如果 On-tree 的实例数远大于 Under-tree训练时 loss 会被多数类主导少数类 AP 往往被压制这会影响后面增强策略的选择。第二如果 580 张图集中在 640×640 附近训练时 imgsz 就定 640如果大量是 1280×720 或更高我建议先统一缩放到 960 左右再训练而不是让 YOLO 内部强行 resize那样既浪费显存还丢小果子细节。2.3 文件路径与图片命名是隐性风险labelme 保存的 imagePath 是标注时的路径可能是D:\data\IMG_001.jpg也可能只是一个文件名。跨机器拷贝数据集后路径分隔符和盘符会让 YOLO 找不到图片。常见做法是在转换脚本里强制用os.path.join(img_dir, os.path.basename(data[imagePath]))只取文件名部分。另一个隐患是中文名或带空格的文件名。Linux 下能读但 ultralytics 某些版本在 dataloader 里拼接路径时对空格敏感训练会随机跳过部分图片。我的习惯是把图片全部重命名为纯英文例如citrus_0001.jpg再开始标注和转换避免后期返工。提示如果 JSON 里 shapes 为空也就是这张图没有任何标注转换时不要生成 txt。YOLO 会把没有 txt 的图当背景样本处理。如果图片本身不含果实最好直接把它从训练集删掉否则相当于给模型增加一张“空目标”干扰图。3. 把labelme JSON转成YOLO格式转换脚本与四个边界坑3.1 转换脚本多边形外接矩形加归一化统计完分布之后就可以做转换。这个脚本把 labelme 的多边形标注转成 YOLO 训练用的 txt 格式每行五个值class_id x_center y_center width height全部归一化到 0~1。核心思路是对每个多边形的点取 min/max 得到外接矩形再用图片宽高做归一化。import json import os import glob import argparse from PIL import Image TARGET_LABELS [On-tree, Under-tree] def clamp(v, lo0.0, hi1.0): return max(lo, min(v, hi)) def convert_one(json_path, img_dir, out_dir): with open(json_path, r, encodingutf-8) as f: data json.load(f) img_path os.path.join(img_dir, os.path.basename(data[imagePath])) if not os.path.exists(img_path): print(f[skip] 图片不存在: {img_path}) return w, h Image.open(img_path).size lines [] for shape in data[shapes]: label shape[label] if label not in TARGET_LABELS: continue # 跳过枝干、叶片等辅助标注 pts shape[points] xs [p[0] for p in pts] ys [p[1] for p in pts] xmin, xmax min(xs), max(xs) ymin, ymax min(ys), max(ys) # 先裁到图片边界内防止越界框导致 w/h 异常 xmin max(0.0, min(xmin, w - 1)) xmax max(0.0, min(xmax, w - 1)) ymin max(0.0, min(ymin, h - 1)) ymax max(0.0, min(ymax, h - 1)) x_center (xmin xmax) / 2.0 / w y_center (ymin ymax) / 2.0 / h box_w (xmax - xmin) / w box_h (ymax - ymin) / h if box_w 0 or box_h 0: continue cls_id TARGET_LABELS.index(label) lines.append(f{cls_id} {clamp(x_center):.6f} f{clamp(y_center):.6f} f{clamp(box_w):.6f} {clamp(box_h):.6f}) if lines: name os.path.splitext(os.path.basename(data[imagePath]))[0] os.makedirs(out_dir, exist_okTrue) with open(os.path.join(out_dir, name .txt), w, encodingutf-8) as f: f.write(\n.join(lines)) def main(): parser argparse.ArgumentParser() parser.add_argument(--json_dir, requiredTrue, helplabelme JSON 目录) parser.add_argument(--img_dir, requiredTrue, help原始图片目录) parser.add_argument(--out_dir, requiredTrue, help输出 txt 目录) args parser.parse_args() os.makedirs(args.out_dir, exist_okTrue) for jf in sorted(glob.glob(os.path.join(args.json_dir, *.json))): convert_one(jf, args.img_dir, args.out_dir) if __name__ __main__: main()用法很简单python labelme2yolo.py --json_dir ./annotations --img_dir ./images --out_dir ./labels脚本里 TARGET_LABELS 的顺序决定了 txt 里 class_id后面 YAML 的 names 顺序必须和它保持一致这是整个流程里最容易翻车的一处。对 circle 类型的标注points 取 min/max 得到的是过圆心的外接矩形虽然框可能偏小但检测任务可以接受如果圆形的圆心和圆周点顺序不确定稳妥做法是拆开解析以 points[0] 为圆心points[1] 到圆心的距离为半径再重建外接矩形。3.2 转换阶段四个高频坑第一个坑是 imageData 内嵌 base64 导致转换脚本变慢。现象是 CPU 飙升、内存翻倍单个 JSON 偶尔几十 MB。原因是部分 labelme 版本保存时把图片一起写进 JSON。解决方法是完全不读 imageData而是用 imagePath 重新读原图脚本里已经这么处理。如果原图缺失直接跳过而不是解码 base64 去恢复图像。第二个坑是覆盖类别的权重顺序。现象是训练时不报错但 val 的 mAP 接近 0。原因往往是 YAML 里 names 列表顺序和转换脚本里 TARGET_LABELS 不一致比如把 Under-tree 放在了第 0 位模型学到的类别名和真实标签错位。解决方法是转换时把映射关系写到 classes.txt训练前打开看一眼。第三个坑是坐标越界。现象是训练到中途 loss 变 NaN或者推理框位置明显偏移。原因是多边形某些点超出了图片边界比如标注时拖拽出画布。虽然代码里用 clamp 做了防御但只对转换后的中心坐标生效如果原 JSON 里 xmin 本身就大于 xmax比如手滑把点顺序填反还是会产生负宽度框。我见过这种畸形标注所以脚本里加了box_w 0的过滤。第四个坑是空标注图被错误删掉。现象是训练时某张图始终没有 loss 贡献模型对这块区域完全无感。原因是在划分训练集时有人会把“没有 label 的图”和“标注丢失的图”混为一谈。实际上没有 shapes 的 JSON 意味着这张图可能是负样本也可能是漏标。如果图中明明有果实但 JSON 里为空应当重新补标而不是直接丢进背景。转换后花十分钟抽查 10 张 txt 的坐标是否和原图对齐比训练后发现漏检要省事得多。4. 用YOLOv8训练自己的柑橘数据集目录组织、YAML与三个必调参数4.1 目录结构与分层划分转换完成后需要把图片和 txt 整理成 YOLO 标准目录结构也就是 images 和 labels 下都分 train、val 两级。Ultralytics YOLOv8 找标签时会把图片路径里的 images 替换成 labels再找同名 txt如果目录结构不对训练不会报错但模型会一路学到空标签最后什么都检不出来。citrus/ ├── images/ │ ├── train/ │ │ ├── citrus_0001.jpg │ │ └── ... │ └── val/ │ └── ... └── labels/ ├── train/ │ ├── citrus_0001.txt │ └── ... └── val/ └── ...划分时不能简单随机抽样。580 张图里可能只有少量图片同时包含 On-tree 和 Under-tree 两个类别。如果 val 集恰好没有 Under-tree验证时的 mAP 对少数类就完全没有参考意义。我一般用分层抽样按图片包含的类别组合分成四类只有 On-tree、只有 Under-tree、两类都有、两类都没有然后从每个桶里按比例抽 val。这样才能保证 val 和 train 的类别分布大致一致。分层划分脚本的关键部分import json import glob import random from collections import defaultdict def label_mask(json_path): with open(json_path, encodingutf-8) as f: data json.load(f) labels set(s[label] for s in data[shapes]) return (int(On-tree in labels), int(Under-tree in labels)) jsons sorted(glob.glob(annotations/*.json)) random.Random(42).shuffle(jsons) groups defaultdict(list) for jf in jsons: groups[label_mask(jf)].append(jf) val_ratio 0.25 for mask, items in groups.items(): n max(1, int(len(items) * val_ratio)) print(mask, val:, n, train:, len(items) - n)实际复制文件时直接根据这个分组结果把图片和对同名 txt 一起移动到对应目录。分层抽样会把少数组合强制放进 val即使该组合只有一两张图。这个强制很重要否则模型对某个类别的泛化能力永远测不出来。4.2 YAML配置names顺序就是训练分类顺序训练前写一个 data YAMLUltralytics 会按它加载路径和类别名。path: citrus train: images/train val: images/val nc: 2 names: 0: On-tree 1: Under-treenames 的顺序必须和转换脚本里 TARGET_LABELS 一致这是整个训练配置里唯一不能靠试错解决的点。如果把 names 写成{0: “Under-tree”, 1: “On-tree”}而 txt 里 0 仍然代表 On-tree模型损失不会报错但推理结果会被当成另一类。我的惯例是转换脚本把索引到类名的映射写到 classes.txtYAML 名直接从文件复制不手敲。4.3 训练命令与三个最该动手的参数目录和 YAML 都准备好后直接用 YOLOv8 开训yolo detect train \ datacitrus.yaml \ modelyolov8n.pt \ epochs120 \ imgsz640 \ batch16 \ device0 \ patience30 \ mosaic0.5 \ close_mosaic10这里有几个参数值得专门讲一下。model 选 yolov8n是因为 580 张图这种量级用小模型更稳yolov8l 参数太多在数据量不够时更容易过拟合曲线好看但不泛化。imgsz 取决于 2.2 节的统计结果如果原图大多在 640 左右就用 640若原图是 1280×1080我建议先缩到 960 再训练而不是直接把 imgsz 设成 1280显存压力和训练速度都会失控。batch 按显存来消费级 8G 卡用 16 稳妥OOM 时优先降 imgsz 而不是降 batch因为检测模型对分辨率更敏感。mosaic 这个增强在果实检测上是个双刃剑。遥感目标检测里小目标密集拼图常靠 mosaic 提升性能但柑橘树的叶子背景本来就是高纹理再拼四张图会让模型看到大量边界被裁断的果实训练分布和真实果园差距更大。我建议对这类数据集把 mosaic 降到 0.5 甚至 0同时保留 hsv 色彩扰动和 scale 扰动因为光照变化才是果园里最真实的干扰。如果全程开满 mosaic训练 loss 降得很快但 val mAP 反而会落后。训练结束后结果落在runs/detect/train下。重点看两件事第一results.png里 train/box_loss 和 val/box_loss 有没有在最后一二十轮分叉分叉就是过拟合第二weights/best.pt是否比last.pt早出现很多如果 early stop 在 40 轮以内触发说明学习率偏大可以把 lr0 从默认 0.01 降到 0.005 重跑。5. 柑橘数据集的避坑排查从标签校验到过拟合5.1 标签与JSON数量对不上训练却“正常”现象训练 loss 曲线很平滑推理时大部分图片也出框了但某些图片一个框都不出。去runs/detect/里翻标签发现对应路径的 txt 行数明显少于源 JSON 的 shapes 数量。原因最常见的是类别名没匹配上比如 JSON 里写的是On-tree转换脚本里写的是on_tree差一个分隔符就被过滤掉了其次是图片文件名带下划线或中文处理时被脚本跳过。解决训练前跑一次校验按图片名逐一比较 JSON shapes 数量和 txt 行数不相等就打印出来。这一步 5 分钟能写完能省掉后面一整天的排查。5.2 增强开满反而让mAP下降现象训练时加了 mosaic、rotation、scaleepochs 拉到 200train loss 很低val mAP 只有默认增强的一半。原因柑橘果实本身接近圆形旋转增强不会改变它的形态但会改变叶子阴影方向和果实之间的相对遮挡关系。模型学到的是“叶子纹理朝这个方向阴影落在左边”而不是“红色圆形物体是果实”在真实果园里自然失效。解决把 degrees 降到 0 或 5mosaic 降到 0.5。保留 hsv 色彩扰动因为不同果园的叶色和果色差异才是真正需要在训练中模拟的变化。5.3 实例不均衡导致Under-tree 被压制现象On-tree 的 mAP50 有 0.85Under-tree 只有 0.6且召回率偏低。原因图片数量相近不代表实例数量相近。On-tree 一张图可能标 8 个果Under-tree 一张图只有 1 到 2 个落果。检测模型的 loss 默认按实例数量加权训练时多数类梯度占比大少数类被压制。解决先按第 2 章脚本统计实例数如果两类的实例 count 比例超过 3:1就把少数类图片在原数据集里复制两到三份再做划分。这种过采样虽然会带来重复图片但对 580 张图的小数据集来说效果往往比换模型更立竿见影。5.4 坐标归一化或class id从1开始导致NaN现象训练第一轮 loss 直接变成 nan或者 loss 正常但推理框全部缩在图片左上角。原因转换时输出了负的 width 或 height或者 class_id 从 1 而不是 0 开始写。YOLO txt 格式要求 class_id 是 0 开始的整数y_center、h 也都必须为正数。解决转换后写一段防御性校验脚本逐行读 txt检查五个值的取值范围import glob for txt in glob.glob(labels/*.txt): for line_no, line in enumerate(open(txt), 1): parts line.strip().split() if len(parts) ! 5: print(f{txt}:{line_no} 字段数不对) continue cls, xc, yc, w, h map(float, parts) if not (0 xc 1 and 0 yc 1): print(f{txt}:{line_no} 中心点越界) if not (0 w 1 and 0 h 1): print(f{txt}:{line_no} 宽高异常)这个脚本要在训练前跑而不是训练后。loss 变 nan 时再去翻 txt往往要花半天。5.5 验证集太小导致mAP像心电图现象训练 loss 曲线正常val mAP 每轮都在 0.5 到 0.9 之间剧烈跳动看不出收敛趋势。原因580 张图里抽 20% 作为 val大概只有 116 张。如果这些图片里有一批特别难的 On-tree某一轮验证刚好抽到其实是固定集但模型在早停后权重变化很大导致波动曲线就会大幅抖动。解决val 比例提高到 30%同时保证每个类别在 val 里至少有 20 个以上实例。如果还抖就直接做 5 折交叉验证每折训练一次取平均。500 张图量级完全跑得起 5 折一颗 GPU 一晚上能出 5 个模型比单次 80/20 划分可靠得多。6. 进阶验证用per-class AP量化On-tree与Under-tree的检测差异6.1 先看per-class mAP而不是总mAP训练完用验证命令看每类指标yolo detect val \ datacitrus.yaml \ modelruns/detect/train/weights/best.pt终端会输出每个类别的 Instances、BoxP、BoxR、mAP50 和 mAP50-95。这里不要盯着总 mAP直接把 On-tree 和 Under-tree 两行单拉出来对比。如果实例数接近但 AP 差很多说明问题不在数据量而在目标本身的视觉复杂度On-tree 被叶子遮挡、密集重叠Under-tree 前景背景分离更干净。如果 AP 差和实例数差成正比优先补少数类数据比如去果园补拍 100 张落果特写效果肯定比换大模型明显。6.2 针对性微调关mosaic、加TTA、补难例针对 On-tree 的漏检我通常先做两步。第一步把mosaic0重训一版确认 AP 变化第二步用带 TTA 的推理看密集遮挡场景是否改善yolo predict \ modelruns/detect/train/weights/best.pt \ sourcetest_on_tree/ \ augmentTrue \ nms_iou0.5augmentTrue 会让模型对同一张图做多尺度推理对密集果实的召回率确实有帮助但会把推理速度拉低 2 到 3 倍部署时要做取舍。然后打开 val 输出目录里的val_batch*.jpg把漏检图挑出来看一眼共同点。如果是果实被树叶遮挡超过一半就到了数据层面把漏检区域裁出来补标 50 张加入训练集比换 yolov8l 更省钱。我做这个数据集时最深刻的教训是第一次转换只图快没保留 classes.txt第二次训练前手改了 YAML 里的 names 顺序mAP 直接掉到 0.1排查了大半天才发现是 0 和 1 两类的映射反了。后来我把“转换脚本输出 classes.txt训练 YAML 直接引用它”写成固定流程再没出过这类问题。五百张图的农业检测数据集价值从来不在把流程跑通而在你能把每一颗红色果实和背景的边界啃到什么程度。希望帮到你。本文还有配套的精品资源点击获取