YOLOv8魔改系列
YOLOv8 双头姿态估计改造方案一、目标在单个 YOLOv8s-pose 模型中同时完成两类目标的关键点检测人体17 个骨架关键点COCO 标准格式车牌4 个角点用于后续透视矫正等核心约束一个模型、一次前向推理、两类关键点同时输出不允许跑两个独立模型。二、整体思路YOLOv8-pose 的结构可以分为三段Backbone → Neck (FPNPAN) → Head (Pose)Backbone 负责提取特征Neck 做多尺度融合Head 负责输出预测。两类关键点的差异完全在于如何预测关键点而与特征提取无关。因此最自然的设计是共享 Backbone 和 Neck在最后一层换成两个独立的 Pose 子头。Backbone → Neck → ┬── person_head (17 kpts) └── plate_head (4 kpts)这样参数量只比单头模型多出一个 plate_head 的参数计算量几乎不变特征提取只做一次同时两个头完全独立互不干扰。三、数据集格式每个标注样本包含 21 个关键点前 17 人体后 4 车牌字段内容cls0person使用前 17 个关键点cls1license_plate使用后 4 个关键点keypoints21×3 张量[x, y, visibility]不属于该类别的关键点槽位填 0训练时由 loss 按类别过滤互不影响。四、修改文件清单文件改动性质说明config/yolov8-dual-pose.yaml新增模型结构配置定义双头ultralytics/nn/modules/head.py新增类DualPose封装两个 Pose 子头ultralytics/nn/tasks.py修改 新增类stride 计算、parse_model 支持、DualPoseModelultralytics/utils/loss.py新增类DualPoseLoss按类别分流的双头损失ultralytics/models/yolo/pose/val.py新增类DualPoseValidator双头验证与指标计算ultralytics/models/yolo/pose/train.py修改自动选择 Model / Validatorultralytics/models/yolo/pose/__init__.py修改导出DualPoseValidator所有原始代码均以注释形式保留不删除。五、各模块详细设计5.1 模型配置 YAML原始 YOLOv8-pose 的 head 末行是单个 Pose 层# 原始-[[15,18,21],1,Pose,[nc,kpt_shape]]改为 DualPose 层传入两组关键点配置# 修改后-[[15,18,21],1,DualPose,[nc,kpt_shape_person,kpt_shape_plate]]同时在 YAML 顶层定义两组形状参数nc:1# 每个子头的类别数kpt_shape_person:[17,3]kpt_shape_plate:[4,3]Backbone 和 Neck 部分与标准 YOLOv8s-pose 完全相同无需改动。5.2 DualPose Headhead.py职责持有person_head和plate_head两个标准 Pose 子头在forward中将颈部特征分别传给两个子头返回各自的输出。关键设计细节特征图列表必须做浅拷贝标准Detect.forwardPose 的父类在前向时会原地修改输入列表x[i] torch.cat(box_feat, cls_feat) # 原地替换槽位两个子头共享同一份颈部特征。若直接把同一个列表传给两个头person_head会把列表元素改写为检测输出66 通道plate_head随后读取时拿到的就是错误的 tensor导致卷积输入通道数不匹配而崩溃。解决方案每次调用前用list(x)做浅拷贝——只复制列表的槽位引用不复制 tensor 本身几乎无额外开销。两个头各自持有独立的列表槽位互不影响。关键设计细节必须显式实现 bias_initDetect.bias_init负责将分类分支的偏置初始化为约 −7对应 sigmoid 后约 0.0009确保初始置信度极低让 TaskAlignedAssigner 能够正常找到正样本。DualPose不是Detect的子类不会自动继承bias_init。如果不显式委托给两个子头执行训练初期会出现cls_loss ≈ 37000初始置信度 0.567200 个 anchor 全部贡献背景 lossbox_loss pose_loss dfl_loss 0IoU≈0 → assigner 找不到正样本 → 无梯度因此DualPose.bias_init必须显式委托给person_head.bias_init()和plate_head.bias_init()。5.3 DualPoseModel 与 tasks.py 修改tasks.pyDualPoseModel继承DetectionModel只覆盖init_criterion方法返回DualPoseLoss实例。其余构建逻辑parse_model、stride 计算、权重初始化复用父类。stride 计算块修改DetectionModel.__init__中有一段 stride 计算逻辑原本只处理Detect及其子类。需要扩展为同时处理DualPose。核心问题在于标准 Pose 在训练模式的forward返回(feat_list, kpt_tensor)二元组stride 循环需要特征图列表有.shape属性。而DualPose.forward返回的是(person_out, plate_out)其中每个*_out又是(feat_list, kpt_tensor)的二元组。因此在 stride 计算的辅助函数_forward中对DualPose需要取person_out[0]person 的 feat_list而非person_out否则x.shape[-2]会报list object has no attribute shape。stride 计算完成后还需将结果同步给两个子头person_head.stride plate_head.stride m.stride因为v8PoseLoss会从子头的.stride属性读取数值。parse_model 修改在parse_model的模块分支字典中新增DualPose的参数处理逻辑从 YAML args 读取[nc, kpt_shape_person, kpt_shape_plate]并将颈部通道列表追加为最后一个构造参数。同时在 task 推断逻辑中注册DualPose→ 返回posetask确保框架能正确识别模型类型。5.4 DualPoseLossloss.py核心问题v8PoseLoss.__init__通过model.model[-1]读取头部的stride / nc / reg_max / kpt_shape。双头模型有两个子头无法直接复用。解决方案轻量 mock 对象为每个子头单独构造一个v8PoseLoss实例。构造时不修改原始模型而是用types.SimpleNamespace构建一个轻量 mock 对象让mock.model[-1]返回对应的子头。为什么用 SimpleNamespace 而不是 nn.Modulenn.Module.__setattr__有类型检查将非 Module 对象赋值给属性model时PyTorch 会抛出TypeError: cannot assign as child module。SimpleNamespace是普通 Python 对象无此限制且不会在原始模型的子模块注册表中留下任何痕迹。batch 分流逻辑batch[cls]是形状[N, 1]的列向量需先.view(-1)展平为[N]再用布尔掩码分别筛出 cls0person和 cls1plate的样本。筛出后每个子 batch 的cls统一改写为 0每个头内部是单类检测器keypoints 按切片提取person 取[:, 0:17, :]plate 取[:, 17:21, :]loss 累加陷阱v8PoseLoss返回(scalar_loss, loss_items[5])。累加总 loss 时必须用zeros(1)标量 tensor而非zeros(5)。若用zeros(5)接收标量标量会广播到 5 个槽位sum()后梯度被 5 倍放大导致训练不稳定。5.5 DualPoseValidatorval.py为什么需要自定义 Validator标准PoseValidator.postprocess直接把模型输出传给non_max_suppression假设输入是单个连续 tensor。DualPose的输出是(person_out, plate_out)嵌套结构直接传入 NMS 立刻崩溃。设计DualPoseValidator继承PoseValidator覆盖以下方法方法改动说明init_metrics从双头模型读取两组 kpt_shape初始化各自的 sigma 和 stats 累加器postprocess拆开双头输出对每个头分别调用 NMSupdate_metrics每张图分别调用 person/plate 两套 GT 过滤 预测匹配get_stats分别计算两头指标结果 key 加前缀fitness 取平均print_results分两行打印 person / plate 指标nc2 陷阱模型用ncdata[nc]2因为 data.yaml 有 person 和 plate 两类构建每个 Pose 子头实际有2 个类别通道。如果在 NMS 中传入nc1NMS 会误把第 2 个类别通道算成关键点数据person 头输出从期望的 57 列变成 58 列51 个关键点列变成 52view(N, 17, -1)无法整除而崩溃。正确做法NMS 传ncself.nc2NMS 后强制将预测类别列置为 0与已被 remap 到 0 的 GT 对齐。final_eval 包装层问题训练过程中验证器接收的是裸DualPoseModelmodel.model[-1]可以直接取到DualPosehead。训练结束后的final_eval步骤框架会将best.pt通过AutoBackend重新加载de_parallel(model) AutoBackend AutoBackend.model DualPoseModel ← 这里 .model 是模型对象不是 Sequential DualPoseModel.model nn.Sequential ← 才是层列表直接de_parallel(model).model[-1]会对DualPoseModel做下标操作报DualPoseModel object is not subscriptable。修复方案循环向下 unwrap.model属性直到遇到nn.Sequential再取[-1]两种场景下均能正确拿到DualPosehead。六、训练流程调整train.pyget_model读取 YAML 配置检测 head 末行是否为DualPose据此选择DualPoseModel或标准PoseModel。set_model_attributes标准PoseModel需要从 data.yaml 读取并设置kpt_shapeDualPoseModel的两组 kpt_shape 已在 YAML 中定义跳过此步。get_validator检测当前模型是否为DualPoseModel是则返回DualPoseValidator否则返回标准PoseValidator。这里需要用de_parallel解包 DDP 包装再判断类型。七、OKS Sigma 设计OKSObject Keypoint Similarity用于衡量预测关键点与 GT 的相似度公式中 sigma 控制每个关键点的容忍半径。头sigma 选择原因person_headCOCO 官方 17 点 sigma与 COCO 评测标准对齐各关键点容忍度按部位差异调整眼、鼻容忍小肩、髋容忍大plate_head全部 0.05紧 sigma车牌角点是精确几何点需要高精度定位用紧 sigma 让评测更严格八、主要调试问题汇总现象根本原因解决方向cls_loss ≈ 37000box/pose/dfl 0bias_init未执行初始置信度全为 0.5assigner 无法分配正样本确保DualPose.bias_init被调用stride 需先计算完毕RuntimeError: expected 64 channels but got 66两个子头共享同一个特征图列表person_head原地修改了列表元素plate_head读到错误 tensorforward中传list(x)浅拷贝shape [N, 17, -1] invalid for size MNMS 传了nc1模型实际 nc2关键点列数多了 1 列无法整除NMS 传ncself.nc事后强制预测类别置 0DualPoseModel object is not subscriptablefinal_eval 中 AutoBackend 包了一层de_parallel(model).model是模型而非 Sequential循环 unwrap.model直到拿到nn.Sequentiallist object has no attribute shapestride 计算DualPose.forward 返回嵌套 tuplestride 循环拿到的是 person 二元组而非 feat_liststride 计算时取person_out[0]feat_list而非person_out九、注意事项bias_init 时序bias_init依赖 stride 已经计算完毕内部通过 stride 计算 bias 初值。tasks.py 中必须先完成 stride 计算并同步给子头再调用bias_init顺序不能颠倒。nc 的真实值模型构建时nc由data[nc]决定等于 data.yaml 中的类别总数会覆盖模型 YAML 中的nc: 1。loss 和 validator 中凡是用到 nc 的地方需用self.nc而非硬编码 1。DDP 解包多卡训练时模型被DistributedDataParallel包装访问内部属性前需先用model.module或de_parallel(model)解包否则会因为DDP对象没有model[-1]等属性而崩溃。