做了半年的樱桃成熟度检测项目,踩了不少坑,也积累了不少实打实的经验,今天把整套东西从头到尾梳理一遍。这套系统基于YOLOv10目标检测框架,用PyTorch实现,训练完成后可以同时支持图像识别、视频文件分析和摄像头实时检测三条通路,源码全是Python,可以直接改直接跑。如果你正打算做果实成熟度检测、农产品分级或者类似的精细目标识别项目,这篇内容应该能帮你省下不少弯路。
先交代一下项目背景。某果园客户提了个需求:樱桃成熟度的人工目检太慢,容易漏,而且不同人标准不统一。他们想要一个能自动判断樱桃是生果、半熟还是熟果的小系统,最好能接摄像头在分选线上实时跑。我拿到需求后第一反应是——这不就是典型的目标检测加三分类问题吗?但实际做下来发现,樱桃成熟度检测比想象中麻烦得多,问题主要出在数据层面:樱桃小、密集、遮挡多、颜色相近的太多,加上不同品种的樱桃成熟色差异很大,这些都会直接影响检测效果。
1. 樱桃成熟度检测的难点与数据方案设计
1.1 成熟度检测为什么不是普通的目标检测任务
普通的目标检测,比如检测行人、车辆、桌子椅子,核心是分清“类别”和“位置”,形态差异往往很明显。但樱桃成熟度检测有一个本质区别:同一颗樱桃在它从生到熟的过程中,形状几乎没有变化,变的只是颜色和光泽度,而且这个变化是连续渐变的,不是跳跃式的。这就导致类别之间的边界非常模糊。
另外,樱桃在树上通常是一簇一簇长的,重叠遮挡极其严重,一颗樱桃可能被另一颗挡住三分之一。再加上自然光线的变化、叶子阴影的干扰,这些问题叠加起来之后,检测模型要学习的不只是“樱桃长什么样”,还要学会“在复杂环境里判断一颗樱桃到底熟到了什么程度”。所以数据方案必须从源头设计好,不然后面训练再怎么调参都救不回来。
1.2 数据采集必须覆盖你真正要用的场景
我在这件事上学到最深刻的一课就是:数据采集的场景务必和目标使用场景保持一致。当时我一开始用的都是网上找的樱桃图片,模型训练出来F1值看着还行,但拿到客户大棚里一测,直接崩了。
原因分析下来很简单:网图大多是摆拍,背景干净、光线均匀、樱桃品相好;而实际果园里是大太阳、逆光、风吹晃动、树枝乱入,甚至还有套袋和防虫网遮挡。模型在“干净图”上学到的特征,在“脏乱场景”里根本泛化不了。
所以第二版数据我全部重采,模拟真实检测场景:
- 在上午9点到11点、下午3点到5点两个时段采集,覆盖不同光照角度;
- 分晴天、多云、阴天三种天气拍,增加光线多样性;
- 挂牌区分品种,把不同成熟度的樱桃分类存放后再拍摄;
- 采集距离从0.3米到1.5米都拍,模拟分选线传送带和手持设备两种距离。
最终凑了大约8500张有效图像,包含单个樱桃的特写、整串樱桃的远景、密集堆叠的平铺图等多个形态。这个数据规模对于单类别细分任务来说是够用的,关键不是总数,而是分布是否覆盖了你真实要跑的场景。
1.3 类别划分与标注规范
樱桃成熟度在不同文献里的定义不太一样,有的分四档、有的分五档,但和客户对齐之后,我们做了非常明确的三分类:
| 类别 | 颜色特征 | 硬度特征 | 用途 |
|---|---|---|---|
| 未熟果 | 青绿色、黄绿色为主 | 果肉硬,光泽弱 | 不宜采摘 |
| 半熟果 | 黄红色、浅红色 | 弹性增加,仍有硬度 | 可短期存放 |
| 成熟果 | 深红、紫红色 | 果肉软,光泽强 | 最佳采摘期 |
这三个类别在RGB空间上其实是有很大重叠的——比如处于转色期的樱桃,不同角度的光一照,颜色既像半熟又像成熟。所以我额外定了一条标注纪律:以樱桃果柄连接处和果腹面的整体颜色为准,不看局部高光区域。标注的时候统一在自然光下进行,避免在屏幕偏色的显示器上做标注。
标注工具用的LabelImg,YOLO格式直接输出txt标签。这一步需要特别提醒:樱桃密集场景下,标注一定是“能看见多少标多少”,那些被遮挡超过70%的樱桃我全部放弃,不标。否则模型训练时会被大量不完整的样本干扰,学到错误特征。
2. YOLOv10相比上一代的核心改动与选型理由
2.1 去掉NMS意味着什么
YOLOv10最大的改动是全面去掉了NMS后处理。以前的检测模型,训练时和推理时逻辑是脱节的:训练时用匹配策略让模型学会预测框,推理时还要依赖NMS做冗余框的抑制。NMS机制本身没问题,但它有两个隐患:一是推理阶段多了一步计算,影响实时性;二是在密集小目标场景下,NMS的阈值怎么调都是两难——阈值调高了大量重叠樱桃会被误删,调低了又会出现重复框。
YOLOv10引入了一致性匹配策略和端到端训练方式,让模型在训练时就直接学到“每个目标只输出一个框”的能力。推理时不需要NMS,直接取TopK输出就行。在樱桃这种密集场景下,这个特性非常香:省去了调NMS阈值的时间,也不会因为NMS误删导致漏检。
2.2 轻量化主干带来的速度收益
YOLOv10n和YOLOv10s都采用了轻量化设计,用更高效的卷积结构替换了传统的大卷积核,同时保留了多尺度特征融合能力。我在实际对比中发现,YOLOv10n在GPU上推理一张640x640的图只要不到3毫秒,比同尺寸的上一代模型快了大约15%到20%。
对于实时检测系统来说,这个速度差异很关键。尤其是接摄像头实时刷帧的时候,每一帧的延迟都会直接影响用户体验。模型推理速度越快,留给前后处理的时间余量就越大。
2.3 为什么这个项目选了YOLOv10s
我当时对比了n、s、m三个尺寸,测试结果如下:
| 模型 | 参数量 | 单张推理耗时 | mAP@0.5 | 实时性评估 |
|---|---|---|---|---|
| YOLOv10n | 2.7M | 约2.8ms | 0.912 | 极佳 |
| YOLOv10s | 8.0M | 约4.1ms | 0.934 | 优秀 |
| YOLOv10m | 16.5M | 约8.5ms | 0.941 | 可用但偏重 |
最终选YOLOv10s做主力,n做备用。原因是:n的精度在干净场景下够用,但一到遮挡严重的密集场景,漏检率会明显上升;m的精度最高,但部署在小算力设备上帧率上不去,而且训练时间翻倍。s是精度和速度的平衡点,在综合场景里表现最稳。
3. 训练脚本与核心参数调优细节
3.1 环境配置与数据集组织
训练环境是Python 3.10 + PyTorch 2.0.1 + CUDA 11.8,GPU是一块普通的消费级显卡,显存不太大,所以训练时做了一些省显存配置。如果你的设备比我这个还紧张,可以适当降低batch size,但注意同步调低学习率。
数据集目录结构要严格按照YOLO格式组织,具体如下:
dataset/ ├── images/ │ ├── train/ # 6800张 │ └── val/ # 1700张 ├── labels/ │ ├── train/ │ └── val/ └── data.yamldata.yaml内容不长,重点是把路径写对、类别名对应好:
train: dataset/images/train val: dataset/images/val nc: 3 names: ['unripe', 'half_ripe', 'ripe']3.2 训练命令与关键参数解释
训练命令如下:
yolo detect train \ data=dataset/data.yaml \ model=yolov10s.yaml \ epochs=200 \ batch=16 \ imgsz=640 \ lr0=0.01 \ lrf=0.01 \ momentum=0.937 \ weight_decay=0.0005 \ optimizer=AdamW \ augmentation=True \ cache=True \ project=runs/train \ name=cherry_v10s这里我踩过的第一个坑是batch size。一开始我图省事用了32,结果显存溢出,后来降到16才稳住。有人可能会问,是不是可以用梯度累积来解决?可以,但说实话,对于这种中小型数据集,batch 16已经足够稳定收敛,没必要为了凑大batch把流程搞复杂。
第二个坑是imgsz的选择。樱桃是小目标,理论上输入分辨率越大越好,我试过640和960。960确实能提升小目标检测精度,但训练时间几乎翻倍,而且部署到摄像头实时检测时,640的FPS明显更稳定,最终方案是训练用640。如果你对精度要求极为苛刻,可以考虑先用640训练,再用960微调。
3.3 训练过程中的指标观察
训练到大概第60个epoch时,loss下降已经明显变缓,这时候不要急着停。樱桃成熟度检测这类任务,loss曲线看着平了,但mAP可能还在涨,因为类别之间的边界模糊,模型需要更多轮次去微调决策边界。
我最终的训练结果:
- 总训练时长:约5.5小时(200个epoch)
- 最佳权重出现在第178个epoch
- mAP@0.5:0.934
- mAP@0.5:0.95:0.781
- 未熟果的召回率:0.957(表现最好)
- 成熟果的召回率:0.912(略低,因为深红色在某些过曝图里偏橙色)
- 半熟果的召回率:0.886(三类里最低,符合预期)
半熟果表现最差是意料之中的,因为它夹在青绿和深红之间,颜色特征最不稳定。如果你也做类似的渐变色分类任务,要做好心理准备,中间档的精度大概率会低于两端。
4. 模型导出与三种检测方式的工程实现
4.1 导出ONNX实现跨平台推理
训练完成后,把PyTorch权重转换为ONNX,做这一步主要是为了部署灵活性。ONNX一方面可以被ONNXRuntime高效加载,另一方面也方便后续用其他推理引擎做加速。
导出命令:
yolo export model=runs/train/cherry_v10s/weights/best.pt format=onnx imgsz=640 opset=12 dynamic=Truedynamic=True表示允许动态输入尺寸,这样在推理时,如果图片不是标准正方形,也能直接送入模型,不需要强行resize。不过要注意,动态导入在部分推理引擎上会牺牲一点性能,如果追求极致效率,可以改用fixed尺寸导出。
4.2 图像检测的具体实现
图像检测是最简单的入口。核心逻辑就是加载图片、跑推理、解析输出、绘制结果、显示或保存。在YOLOv10中,由于模型本身已经端到端去掉了NMS,输出解析阶段省掉了一个大麻烦,直接读取所有检测结果的边界框置信度和类别标签就行了。
完整代码如下,可以直接用:
import cv2 from ultralytics import YOLO model = YOLO("best.onnx") def detect_image(image_path, save_path=None): img = cv2.imread(image_path) results = model.predict(img, conf=0.35, iou=0.45) for result in results: boxes = result.boxes.xyxy.cpu().numpy() classes = result.boxes.cls.cpu().numpy() scores = result.boxes.conf.cpu().numpy() for box, cls, score in zip(boxes, classes, scores): x1, y1, x2, y2 = box.astype(int) label = f"{model.names[int(cls)]} {score:.2f}" color = class_color(int(cls)) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2) if save_path: cv2.imwrite(save_path, img) cv2.imshow("Cherry Detection", img) cv2.waitKey(0)这里有个细节值得留意:置信度阈值conf的设置。刚开始我用默认的0.25,结果成熟果和半熟果之间出现了大量误判,因为这两类在模型内部的得分差异本来就不大。后来把阈值提高到0.35,误检明显减少。但阈值也不是越高越好,太高会把低置信度的真实目标直接滤掉,具体的数值要结合你自己的数据分布来试。
4.3 视频文件分析与逐帧处理策略
视频检测的逻辑是不断读取视频帧,逐帧送入检测模型,再把检测结果绘制到每一帧上,最后用视频编写器写回新文件。这里要重点处理两个问题:帧率控制和画面卡顿感。
import cv2 from ultralytics import YOLO model = YOLO("best.onnx") video_path = "cherry_demo.mp4" output_path = "cherry_output.mp4" cap = cv2.VideoCapture(video_path) fps = cap.get(cv2.CAP_PROP_FPS) width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) writer = cv2.VideoWriter(output_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)) while cap.isOpened(): ret, frame = cap.read() if not ret: break results = model.predict(frame, conf=0.35) annotated = results[0].plot() writer.write(annotated) cv2.imshow("Video Detection", annotated) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() writer.release()推理速度如果跟不上视频原始帧率,会造成播放卡顿、输出视频比原始视频长的情况。最简单的优化方法是跳帧检测:每两帧或三帧检测一次,中间帧直接复用前一帧的检测结果。对于传送带上的樱桃检测来说,相邻帧之间目标位置变化很小,这种做法在实际体验上几乎无感。
4.4 摄像头实时检测的完整代码
摄像头实时检测是整套系统里最有实用价值的部分,因为分选线上跑的就是摄像头。代码框架和视频检测非常像,核心区别是视频源从文件路径换成摄像头ID,加入实时显示逻辑。
import cv2 from ultralytics import YOLO model = YOLO("best.onnx") cap = cv2.VideoCapture(0) if not cap.isOpened(): print("无法打开摄像头") exit() cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) while True: ret, frame = cap.read() if not ret: break results = model.predict(frame, conf=0.35, device="cuda:0") annotated = results[0].plot() # 统计各类别数量 cls_list = results[0].boxes.cls.cpu().numpy() unripe = sum(1 for c in cls_list if c == 0) half = sum(1 for c in cls_list if c == 1) ripe = sum(1 for c in cls_list if c == 2) info_text = f"Unripe: {unripe} | Half: {half} | Ripe: {ripe}" cv2.putText(annotated, info_text, (20, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) cv2.imshow("Real-time Cherry Detection", annotated) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()实时检测场景下,务必用GPU推理。我试过纯CPU跑YOLOv10s,640分辨率输入下帧率只有五六帧,用起来非常卡。换到GPU之后能跑到45帧以上,体验完全不同。如果你手头没有GPU,可以考虑换用YOLOv10n模型,虽然精度会下降一些,但CPU推理速度能提升不少。
5. 实测踩坑记录:光线、遮挡与成熟度误判
5.1 过曝环境下成熟果变“橙果”
这个坑可以说是所有果实成熟度检测项目里最容易遇到的,我花了很长时间才弄明白根因。
大棚里正午的阳光非常强,樱桃表面是光滑的球形,会在局部产生高光反射。一颗已经熟透的深红色樱桃,在强光下高光区域会变成亮橙色,模型只看局部颜色的话就会把它判成半熟果。我第一版模型在大棚实测时,成熟果的召回率直接断崖式下降。
排查过程是这样的:我先怀疑是模型过拟合,加了更强的数据增强,结果没用;又怀疑是图片分辨率不够,小目标细节丢失,换成960输入,还是只提升了一点点。最后我把视频里误判的那些帧逐帧截出来,和训练集里的成熟果图片放在一起对比,才发现问题——不是模型训练得不好,是训练集里根本没有“带强高光的成熟果”样本。
针对性地补了一批逆光和高光环境下的成熟果图片,再重新训练之后,这个问题基本解决。如果你也在做类似项目,有个方法值得参考:误检样本积累法。把每次实测中错误的检测结果自动截屏存进一个新文件夹,定期把这些图片人工校准后并入训练集,效果提升非常明显。我最终的数据集里大概有15%的图片就是靠这种方式迭代补进去的。
5.2 遮挡樱桃的漏检问题
樱桃长在树上是一串一串的,这种物理形态决定了遮挡不可避免。我统计过,密集簇状樱桃场景中,超过30%的目标都存在不同程度遮挡。YOLOv10虽然在小目标检测上表现不错,但高度遮挡目标的漏检率依然偏高。
针对这个问题,我做了三次实验对比:
第一次,直接加大训练集中密集簇状图的比例,从25%加到55%,漏检率下降了一些,但变化没有想象中明显;
第二次,尝试提高输入分辨率到704,利用模型对更大特征图的敏感度提升小目标检测能力,有效果,漏检率继续下降了几个点,但训练时间和显存占用也上去了;
第三次最有效——把训练集中被遮挡目标的标注策略从“全标”改成“只标可见面积大于五成的”,模型学到了一个认知:看不见大部分区域的樱桃就不要强行输出框,反而让漏检率显著下降。
5.3 半熟果标注边界不统一引发的模型摇摆
半熟果误判率最高,主观标注不一致是一个重要影响因素。在联合标注的过程中,两个标注员对同一颗樱桃往往会有不同判断——一个认为是半熟,另一个认为已经是成熟果了。这会导致模型收到互相矛盾的训练信号,边界模糊度变高。
我当时的解决办法是制定更细致的标注规范:生成一张六档色卡作为参考基准,所有标注员先经过同一套样本的考核测试,前后标注一致率达到90%以上才允许开始正式标注。同时每隔一段时间随机抽取已标样本复检,发现歧义样本就单独讨论统一标准。这套流程过后,半熟果的召回率从0.81提升到了0.86,提升明显。
5.4 一套实时性的调优组合拳
摄像头实时检测的流畅度直接影响生产可用性。我最后固定下来的调优组合是:
- 输入尺寸固定在640,不启用动态尺寸;
- 用TensorRT或ONNXRuntime替代PyTorch原生推理;
- 开启半精度FP16推理,显存占用减半,速度提升明显;
- 在传送带高帧率场景,采用隔帧检测加预测框平滑策略。
按这套组合,在普通消费级GPU上,视频流检测能达到40到50FPS,完全满足分选线上的实时要求。
6. 模型部署与后续项目扩展思路
6.1 导出部署组件的完整命令
YOLOv10在ultralytics框架里的部署很顺畅,和上一代流程基本一致。除了前面提到的ONNX之外,还可以导出TensorRT引擎或者OpenVINO格式,按目标平台选择即可。
yolo export model=runs/train/cherry_v10s/weights/best.pt format=tensorrt device=0TensorRT导出核心优势是推理速度更快,以我这个项目的数据来看,加速比大约1.8倍,从4.1毫秒缩到2.3毫秒左右。缺点是构建时间较长,而且绑定具体的GPU型号和数据精度,迁移到别的显卡上需要重新构建引擎。
6.2 一套简单有效的成熟度统计逻辑
检测本身只是第一步,真正对客户有价值的是统计功能。我实现了一个最简单的统计器类,每帧检测后更新各类别数量,并输出一段时间内的平均值。这个逻辑用在传送带上可以实时显示“这一批樱桃中成熟果占多少、半熟果占多少”,为分选决策提供直接参考。
class MaturityCounter: def __init__(self, window_size=30): self.window = [] self.window_size = window_size def update(self, counts): self.window.append(counts) if len(self.window) > self.window_size: self.window.pop(0) return self.average_counts() def average_counts(self): if not self.window: return [0, 0, 0] sums = [0, 0, 0] for c in self.window: for i in range(3): sums[i] += c[i] return [s / len(self.window) for s in sums]滑动窗口一个月后,统计结果就稳定了,不再因为个别帧的波动导致数据跳来跳去。
6.3 后续可以怎么扩展
如果你想把这套系统继续往下做,有几个方向值得考虑:
第一个方向是接入机械臂或分选机构,根据检测结果实时控制机械手,把成熟果和未熟果分离开。这需要检测系统与运动控制系统的时序配合,建议用消息队列做异步通信,避免检测延迟直接影响机械臂动作。
第二个方向是引入深度估计或双目视觉,计算樱桃的空间坐标,指导机械臂精准抓取。目前单目相机只能提供二维框,想要三维定位,双目方案是最自然的延伸。
第三个方向是做一个Web端管理界面,用Flask或FastAPI封装检测服务,浏览器端上传图片或视频就能看到检测结果,同时把统计数据可视化,方便管理人员远程监控分选线的工作状态。
从我实际做的项目角度来说,最推荐第三个扩展方向,因为它成本低、见效快,而且客户能直接感知到系统的实用性价值。
写在最后的一点个人经验
这整套系统从前期的数据采集、标注规范制定,到模型选型、训练调参,再到三种检测方式的工程实现和后续的部署优化,前前后后花了大概两个月时间。纵向上看,YOLOv10本身的代码质量和生态完善程度帮了大忙;横向上看,真正拉开效果差距的永远是数据质量和对具体场景的理解深度。如果你只是把模型跑通,那不难;难的是让你的模型在真正复杂的环境里依然稳得住。我的建议是:不要急着换网络结构、刷最新框架,先花时间把数据打磨扎实,把场景里的边缘情况全部想到。模型输出的置信度、召回率、FPS这些数字,最终都要回归到真实世界的光照、遮挡和误差里去检验。