☰
两阶段目标检测+关键点回归:茶叶嫩芽芽尖精准定位实践
2026/9/29 3:41:06 网站建设 项目流程

简介:针对茶叶嫩芽目标检测与关键点定位任务,这份压缩包提供一套两阶段模型的完整工程化方案,适合从事农业视觉、智慧茶园、自动化采摘等方向的算法工程师、研究人员和高校学习者参考复现。方案涵盖YOLO系列或Faster RCNN的目标检测主干,以及嫩芽关键点定义与回归映射模块,可服务于嫩芽质量评估、生长状态监测和采摘决策等场景。压缩包共1766个文件,大小约803.27MB,以944个jpg现场图像和795个json标注文件为基础数据,配套20个py脚本实现数据预处理、模型训练与推理流程,pt、pth、torchscript等模型文件提供预训练权重与部署版本,另有bat脚本便于一键启动。已有699人下载学习。内容涵盖图像数据、标注文件、训练脚本与模型权重几大模块,可支撑从数据预处理、模型训练到权重部署的完整流程,适合作为课程设计、论文实验或工程项目的基线方案,也可迁移到其他嫩芽、小目标关键点检测任务中。

1. 茶叶嫩芽检测为什么必须走“两阶段”:先找到芽,再找芽尖

做农业视觉落地的人多半遇到过这类场景:茶园里的嫩芽和成熟叶片颜色接近、芽体细小,一丛茶树枝叶互相遮挡,清晨或背光时嫩芽边缘几乎融进背景。拿通用目标检测模型直接上,你会发现框能出来,但框里的“芽尖”到底在哪个像素上,模型完全不关心。可茶园自动采摘、嫩芽分级、芽叶比统计这类需求,恰恰需要知道芽尖精确位置和姿态——这是单阶段检测模型给不了的。

这个标题里的方案,核心思路是两阶段串联:第一阶段用目标检测把嫩芽区域定位出来(是什么、在哪片区域),第二阶段在已定位的区域内做关键点检测,输出嫩芽的芽尖、叶柄等关键点位置,最后通过“目标-关键点-回归映射”把关键点坐标从区域坐标还原到整幅图像坐标。等于先划重点、再做精读,而不是让模型一口气干完所有事。这套路线适合做茶园采摘机器人视觉、嫩芽长势监测、茶叶分级设备的人,也适合想在目标检测项目里加入关键点输出的开发者。

我按这个标题做下来的经验是:两阶段不只是网络结构的堆叠,数据标注、损失函数、坐标映射任何一个环节出错,模型精度都会卡在某个阈值上不去。下面从模型搭建、标注格式、训练参数、避坑经验到验证手段,把整条链路拆开讲。

2. 两阶段模型结构:目标分支、关键点分支与回归映射的联动方式

2.1 为什么不用单阶段模型直接把关键点回归出来

有同行问过:既然关键点就是几个坐标值,能不能让检测头多输出几个通道,把芽尖坐标一起回归了?我在早期实验里试过这种“一步到位”的做法,效果不理想。

原因在于:嫩芽目标普遍只有三四十个像素,在整张茶园图像里占比极小,单阶段模型要同时优化“目标在哪”和“关键点在哪”两个任务,特征冲突很严重——分类和回归分支会把梯度集中到高频特征上,小目标的芽尖位置被平均掉。更麻烦的是,关键点坐标是绝对坐标的话,模型得为每个目标都输出一组高精度数值,训练收敛慢,推理时还容易出现关键点漂移。

两阶段模型的好处是任务解耦:第一阶段只负责“框出嫩芽”,第二阶段只在框内做局部关键点回归,特征已经对齐过一次,任务各自单纯。实际工程里,两阶段方案在嫩芽这类小目标上的关键点误差,通常比单阶段低30%到50%,这是结构本身带来的收益,不是调参能弥补的。

2.2 头部结构:检测头加关键点头,怎么把特征“抠”出来

常见的做法是共享backbone(可选择ResNet50或CSPDarknet),backbone输出特征图后分两条支路。目标检测支路负责框回归和置信度分类;关键点支路接收检测支路输出的候选区域(Region of Interest),在候选区域对应的特征图上做关键点热图回归或坐标回归。

我习惯把关键点支路设计成三部分——ROI特征提取、关键点头、坐标映射层。ROI特征提取用PSROIAlign或ROIAlign把不同尺寸的候选区域统一成固定尺寸特征图(比如14×14或7×7),保证后续全连接层能接收定长输入。关键点头可以是简单的卷积加全连接,输出形式有两种:直接回归归一化坐标,或者输出2D热图再取峰值。

下面给一个简化的PyTorch风格结构定义,方便理解两个分支的连接关系:

import torch import torch.nn as nn import torchvision.ops as ops class TeaBudTwoStage(nn.Module): def __init__(self, backbone, num_classes=1, num_keypoints=2): super().__init__() self.backbone = backbone # 比如 ResNet50,输出 C4 特征图 self.detection_head = nn.Sequential( nn.Conv2d(1024, 256, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(256, num_classes * 4 + 1, 1) # 每个anchor: 4个框坐标 + 1个目标置信度 ) self.roi_align = ops.RoIAlign(output_size=(7, 7), spatial_scale=1/16, sampling_ratio=2) self.keypoint_head = nn.Sequential( nn.Linear(1024 * 7 * 7, 512), nn.ReLU(inplace=True), nn.Dropout(0.2), nn.Linear(512, num_keypoints * 2) # 每个关键点输出 (dx, dy),相对ROI左上角的偏移 ) def forward(self, images, rois): feat = self.backbone(images) # 输入 [B,3,H,W] -> 特征 [B,1024,H/16,W/16] det_out = self.detection_head(feat) # 检测头输出,shape 与 feat 一致 roi_feat = self.roi_align(feat, rois) # 按 RoI 裁剪并 resize 到 7x7 b = roi_feat.size(0) roi_feat_flat = roi_feat.view(b, -1) kpt_offset = self.keypoint_head(roi_feat_flat) # 输出相对坐标偏移 return det_out, kpt_offset

这段代码里,det_out的最后一维是“每个anchor的4个坐标偏移加1个置信度”,实际训练时可换成Faster R-CNN或Cascade R-CNN的标准输出头,道理一样。rois来自检测分支的候选框,格式是[x1, y1, x2, y2],注意RoIAlign要求的是原图坐标系下的绝对坐标,而不是归一化坐标。

关键参数说明:

  • spatial_scale=1/16:backbone下采样倍数,ResNet50的C4输出是原图的1/16,如果你的backbone是下采样32倍,这里要改成1/32,否则特征对齐位置会整体偏移。
  • RoIAlign output_size=7:统一ROI特征的空间尺寸,7×7是常见选择,尺寸越大保留的空间细节越多,但计算量增大;嫩芽目标小,我一般用10×10或14×14,关键点定位误差更低。
  • 关键点头最后一层输出num_keypoints * 2:每个关键点两个数,表示相对ROI左上角的偏移量。输出后用Sigmoid限制在[0,1]区间,再乘以ROI宽高即可得到原图坐标。

2.3 目标-关键点-回归映射:坐标还原的三个公式

这是标题里的核心概念,也是很多实现翻车最多的地方。关键点头在ROI坐标系下回归出相对偏移,最终必须映射回原图生成绝对坐标,才能交给下游的机械臂或统计模块。

映射公式如下:

原图关键点 x = roi_x1 + offset_x * roi_width 原图关键点 y = roi_y1 + offset_y * roi_height

其中roi_x1是检测框左上角在原图上的x坐标,roi_width是检测框宽度,offset_x是网络输出的归一化横向偏移(0到1之间)。

如果关键点头输出的是热图而不是偏移,映射方式变为:先在ROI特征热图上找响应值最大的像素位置,再乘以ROI在特征图上的缩放比例,最后加上ROI在原图的左上角坐标。两种方式本质相同,都是“局部坐标 + 全局偏移”的组合。

这个映射在训练时和推理时各要做一次。训练时把标注的关键点绝对坐标转换成ROI相对坐标作为监督目标;推理时反过来,把网络输出的相对坐标还原成绝对坐标。务必保证两个过程互逆,否则会出“训练时效果好、推理时关键点位置偏半个身位”的诡异问题。

3. 数据集与标注格式:关键点标注做不好,两阶段模型直接失去意义

3.1 嫩芽关键点标注:定义哪几个点最实用

做目标检测时标注只需要一个矩形框,但加上关键点后,必须先定义清楚“关键点是什么”。我做茶园项目时按采摘需求定义了两类关键点:

  • 芽尖(bud_tip):嫩芽最顶端的像素位置,是采摘机械臂的切割参考点。
  • 芽柄基部(bud_base):嫩芽与枝干连接处的像素位置,用于判断嫩芽朝向和长度,也用于计算芽叶比。

如果做的是茶树表型分析,可以再加“第一片展开叶叶柄点”“第二片展开叶叶柄点”,但采摘场景下两个点足够,点多反而增加标注不一致的风险。关键点数量建议控制在2到4个,每个点都要有明确的像素级定义,标注规范里最好配两张示例图:正确标注和错误标注。

3.2 数据格式:从JSON到训练样本的转换脚本

常见的关键点数据集格式是MS COCO的扩展格式,检测框用[x, y, width, height]表示,关键点用[x, y, visibility]数组表示,其中visibility取0、1、2,分别表示“未标注”“遮挡但存在”“可见”。因为嫩芽经常被叶片遮挡,芽基部可能看不到,visibility字段一定要利用起来,训练时不可见的关键点不参与损失计算。

我一般把数据整理成自定义JSON,结构如下:

{ "image": "tea_bud_001.jpg", "width": 1920, "height": 1080, "annotations": [ { "bbox": [623.4, 213.8, 54.2, 41.6], "keypoints": [ [640.7, 222.1, 2], [656.2, 241.8, 1] ] } ] }

这一段里,bbox是检测框左上角坐标和宽高,两个关键点分别为芽尖和芽基。visibility=1的芽基被遮挡,训练时该点的loss置零。

下面给一个从标注JSON转到训练输入的脚本片段,核心是坐标归一化和映射:

import json import numpy as np def load_annotation(json_path): with open(json_path, 'r', encoding='utf-8') as f: ann = json.load(f) rois, kpt_abs, kpt_rel, kpt_vis = [], [], [], [] for item in ann["annotations"]: bx, by, bw, bh = item["bbox"] rois.append([bx, by, bx + bw, by + bh]) # 转成 [x1,y1,x2,y2] kps = np.array(item["keypoints"], dtype=np.float32) kpt_abs.append(kps[:, :2]) vis = kps[:, 2] # 关键:把绝对坐标映射成ROI内的相对偏移 rel = (kps[:, :2] - [bx, by]) / [bw, bh] kpt_rel.append(rel) kpt_vis.append(vis) return rois, kpt_abs, kpt_rel, kpt_vis

注意脚本里转换的次序:先取出绝对坐标和可见性,再除以框的宽高得到相对偏移。这里最容易出错的是分母用了图片宽高而不是框宽高——如果按图片宽高归一化,网络会同时学习“关键点在图片里的绝对位置”和“关键点在框里的相对位置”,两者冲突,训练震荡。

3.3 数据增强和拼图要注意的事

嫩芽数据集通常不大,几百张到一两千张图是常态,没有预训练权重加持的话,两阶段模型学不出来。我一般加载在COCO或ImageNet上预训练的backbone权重,然后做在线增强:随机翻转(注意关键点左右对调)、随机旋转10度以内、HSV抖动、高斯模糊模拟阴天。

但有一条铁律:进行仿射变换或裁剪时,检测框和关键点坐标必须同步变换,而且翻转时关键点顺序必须重排。比如芽尖和芽基在左右翻转后,两个点的左右关系互换,如果不对关键点索引做映射,训练loss会一直不降,复现时最容易卡在这一步。数据量少时还可以复制粘贴小目标,从不同图上把嫩芽区域抠出来贴到训练图上做遮挡模拟,前提是目标-关键点回归映射保持一致。

4. 训练配置与损失函数:目标分支和关键点分支的博弈

4.1 两个损失怎么配比:直接相加会出问题

两阶段模型有两个监督信号:目标检测损失和关键点损失。常见做法是让关键点损失只作用于被检测出来的ROI,但训练初期检测分支本身还不准,ROI位置有偏移,关键点分支等于在“被污染”的区域上学习,容易不收敛。

我的做法是分阶段训练。第一阶段冻结backbone的前几层,先单独训练检测分支直到mAP达到基础水平;第二阶段解冻全部参数,加上关键点分支一起联合训练。损失公式如下:

total_loss = w1 * det_cls_loss + w2 * det_reg_loss + w3 * kpt_loss

kpt_loss不是对所有ROI都计算,只对与真实框IoU大于0.5的ROI计算。w1、w2、w3建议初始值分别设为1.0、1.0和0.5,然后观察收敛曲线调整;如果关键点loss降不下去,说明检测框质量差,优先回头修检测分支而不是调loss权重。

4.2 关键点损失函数选型:SmoothL1还是Wing Loss

关键点坐标回归是连续值回归,最常见的是SmoothL1 Loss,它对离群点不敏感,收敛平稳。但嫩芽关键点标注本身有像素级噪声(不同标注员可能差两三个像素),直接用SmoothL1对小偏差不敏感,可能导致模型在细微坐标上“无所谓”。

Wing Loss在这里效果更好,它对小误差范围的梯度更大,专门用于人脸关键点这类高精度任务,对嫩芽这种毫米级定位场景也适用。Wing Loss的公式略复杂,但PyTorch里手写也就十几行:

import torch import torch.nn as nn import math class WingLoss(nn.Module): def __init__(self, w=10.0, epsilon=2.0): super().__init__() self.w = w self.epsilon = epsilon self.C = w - w * math.log(1 + w / epsilon) def forward(self, pred, target, visible): diff = pred - target diff = diff * visible.unsqueeze(-1) # 不可见的关键点不参与 abs_diff = diff.abs() loss = torch.where( abs_diff < self.w, self.w * torch.log(1 + abs_diff / self.epsilon), abs_diff - self.C ) return loss.mean()

visible是形状为[num_rois, num_keypoints]的掩码,值为0或1,对应标注里的visibility字段,用unsqueeze扩到与坐标维度一致后乘在差值上,不可见点位梯度为0。

参数说明:w控制线性区宽度,epsilon控制曲线曲率。对茶叶嫩芽这种像素精度要求高、标注噪声也高的场景,我推荐w=10、epsilon=2;如果是遥感或大目标场景,w=5更合适,因为坐标噪声相对目标尺寸没那么多。

4.3 训练脚本关键参数清单

训练配置直接决定模型能不能收敛。下面是我在单卡NVIDIA RTX 3090上验证过的一组合适参数,供参考:

参数推荐值说明
输入分辨率1333×800原图16:9缩放,保小目标细节
batch size4两阶段模型显存占用高,batch过大易OOM
基础学习率0.001加载预训练权重时从1e-4起步更稳
学习率衰减每10个epoch乘以0.1训练到20-25轮会明显下降
关键点损失权重0.5起步联合训练时不要一开始给太高
ROI采样数128正负样本比1:3,保证关键点分支看到足够正样本
NMS阈值0.5两阶段模型内部处理候选框用
最大epoch50嫩芽数据集1000张图,50轮可收敛

训练时建议把三个loss分别打印出来记录到日志,不要只盯total loss。如果kpt_loss在联合训练初期反而上升,说明检测分支还在调整框位置,关键点分支被迫跟着学,这种抖动在前5-10个epoch内正常;超过15轮还在抖,检查ROI采样逻辑和数据标注一致性。

4.4 目标-关键点-回归映射在训练脚本里的实现槽位

训练代码里对应关系是这样组织的:detection_head输出候选框,进NMS后得到rois;rois和真实关键点一起交给数据加载器,训练时直接用真实框做RoIAlign,称为“正样本ROI训练”;推理阶段则换成检测预测的框做RoIAlign。两者必须用同一套映射公式,否则模型在训练和推断时看到的输入分布不一致,典型表现是验证集loss很低但实际推理关键点位置发飘。

5. 避坑与常见问题排查:嫩芽目标小,坑比想象中多

5.1 现象:训练损失下降但关键点整体偏移半厘米

原因:回归映射公式写错了,最常见的是RoIAlign的spatial_scale和backbone实际下采样倍数不一致。比如backbone输出stride=32,但代码里写1/16,ROI特征被拉伸了两倍,关键点上采样回原图时坐标全体偏移。

解决:打印backbone对单张图的输出尺寸,算出实际stride再做映射;或者干脆用end2end方式从backbone向前传一次确定输出shape,不要在配置里手写hardcode。

5.2 现象:两个重叠嫩芽,检测框一个大一个小,关键点经常串位

原因:嫩芽彼此紧邻时,NMS会保留两个有重叠的框,第二个阶段同时对两个ROI提取特征,如果RoIAlign的采样点太少,相邻嫩芽的特征混在一起,关键点头区分不开。

解决:把RoIAlign的sampling_ratio从2提高到4,增加采样点密度;同时把NMS阈值从0.5降至0.4,减少重叠框数量。如果还不行,在关键点头前加一个小型自注意力模块,让每个ROI内部的特征关注到该ROI对应的嫩芽本体。

5.3 现象:训练集mAP不错,但傍晚和逆光条件下关键点全部漂移到叶片边缘

原因:训练数据里缺少弱光场景。嫩芽在逆光下是半透明的嫩黄色,边缘特征弱,模型只能靠形状先验猜芽尖位置,一旦光照分布偏移,先验失效。

解决:在数据增强里增加亮度扰动和局部阴影模拟,把HSV的V通道随机乘0.6到1.4,再加一个随机的径向渐变模拟侧光和背光;另外单独收集一个时段的弱光子集做微调。这类问题没有模型结构上的捷径,只能把数据分布补齐。

5.4 现象:visible=1(可见但遮挡)的关键点loss在训练时反而干扰正常点

原因:visible=1的点label位置是人工估计的,标注员在遮挡情况下画的位置偏移比其他点大;SmoothL1或Wing Loss给这些模糊点分配正常梯度,模型被迫拟合错误监督。

解决:把visible=1和visible=2分开处理。visible=1的点loss权重降为0.3,visible=2正常参与。实现上在构建visible掩码时额外乘一个权重因子即可,代价极小但收敛稳定性明显提升。

5.5 现象:训练时OOM,显存爆掉

原因:两阶段模型在训练阶段要为每个ROI单独做RoIAlign和关键点计算,batch里候选框多时显存开销暴涨,尤其是用了14×14以上output_size。

解决:减小batch size到2,同时限制每张图最多保留256个候选ROI;如果还爆,把关键点头的全连接层改成1×1卷积加全局平均池化,可省掉约三分之一显存。不要在模型结构已经写死之后硬调batch,先剪ROI数量再降输出尺寸。

6. 验证与进阶:用OKS评估关键点,让模型真正落到茶园

6.1 检测与关键点分别算指标,别混在一起

两阶段模型要分开评估。检测部分看AP@0.5和AP@0.75,判断定位精度是否够用;关键点部分用OKS(Object Keypoint Similarity)评估,公式里需要设定每个关键点的标准差sigma,芽尖和芽基的sigma我设为20像素,对应茶园图像上约0.5厘米的允许误差。OKS阈值为0.75时,关键点精度才算“可被机械臂使用”。

评估脚本可以直接用COCO API的cocoEval,把标注转成COCO格式后调用即可。另外务必统计推理耗时,两阶段模型检测加关键点回归在GPU上一般30-50ms,但到嵌入式端会到100ms以上,采摘场景通常要求单帧80ms以内,提前做好量化或换轻量backbone的预算。

6.2 进阶技巧:把关键点收益反馈到检测分支

联合训练稳定后,可以做一次可选的refine:用关键点预测结果修正检测框。具体做法是计算芽尖和芽基的连线,外扩一定比例得到更贴合嫩芽姿态的新框,把新框重新送进ROIAlign后再预测一次关键点,迭代一次。这个操作能提升关键点整体OKS约两个点,代价是推理时多一次前向。

我在茶园实地跑这套方案时发现,最影响落地效果的不是mAP,而是关键点的时域稳定性——同一片叶片在连续帧里抖动几像素,机械臂就会来回颤。一个实用的技巧是输出前用卡尔曼滤波平滑芽尖坐标,以芽尖历史位置做预测、当前帧检测结果做观测,噪声能降一半以上。这不属于模型结构问题,但比换模型更直接有效。

6.3 给关键点预测画热力图,先于标注检查错误

提高验证效率有个好手段:把关键点头的中间层热力图可视化出来,叠在原图上。如果热力图高响应区域不是芽尖而是叶缘或茶枝,说明模型学到了错误上下文;这时不要盲目调参,先检查标注图形状是否一致,再决定是增数据还是约束映射分支。两阶段模型的结构收益都在局部特征里,热力图是唯一能直观看到模型内部选择的窗口。

做这个方向最深的教训是:茶叶嫩芽的数据永远不会干净,遮挡、逆光、露珠反光全都是常态。两阶段模型解决的是“目标本身太小、关键点需要高精度”的结构问题,但数据质量决定精度的天花板。先花时间统一标注规范、补齐弱光场景样本,再谈改网络结构,不会走冤枉路。希望这套目标-关键点-回归映射的实践记录能帮你在茶园场景里少踩几个坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询