跳转至

第12章 进阶主题

本章目标

了解 RoboMaster 视觉系统中更高级的算法模块。完成本章后,你将理解 MPC 轨迹规划、全向感知、导航决策、YOLO 检测器进阶用法以及反陀螺算法的核心思想,为阅读和贡献 sp_vision_25 的进阶代码打下基础。

预计用时: 2-3 天(本章内容较多且相互独立,可按需选读)

前置知识: 第7-10章的核心自瞄链路


12.1 MPC 轨迹规划器

传统自瞄的局限

传统自瞄(第10章介绍的弹道解算 + PID 控制)存在一个根本性问题:它只处理当前时刻的信息

sequenceDiagram participant Vision as 视觉检测 participant Controller as PID控制器 participant Gimbal as 云台 Vision->>Controller: 目标 yaw(当前帧) Controller->>Gimbal: 输出 yaw 角速度(当前帧) Note over Controller: 没有考虑
目标未来怎么动 Vision->>Controller: 目标 yaw(下一帧) Note over Controller: 纠正上一帧的误差 Controller->>Gimbal: 输出 yaw 角速度(下一帧)

PID 的问题:

  • 滞后:目标在移动,但 PID 只能根据当前误差做反应,永远"追着目标跑"
  • 无预测:不知道目标下一刻会往哪个方向运动
  • 开火决策粗糙:只有"偏差足够小就开火"这一个判断,没有考虑偏差趋势

MPC 自瞄:轨迹视角

MPC(Model Predictive Control,模型预测控制)的核心思想是:不只看当前,而是看未来一段轨迹

关键洞察——弹丸飞行需要时间:

\[ \text{yaw\_shoot}(t) = \text{yaw\_target}(t + t_{\text{fly}}) \]

含义:在时刻 \(t\) 开火,弹丸要经过 \(t_{\text{fly}}\) 时间才能到达目标,所以云台应该瞄准目标在 \(t + t_{\text{fly}}\) 时刻的位置,而不是 \(t\) 时刻的位置。

graph LR subgraph "传统自瞄" A1["目标现在在这里"] --> B1["云台对准这里"] C1["弹丸到达时
目标已经移走了"] -.-> B1 end subgraph "MPC 自瞄" A2["预测目标未来位置"] --> B2["云台对准这里"] C2["弹丸到达时
目标刚好在这里"] -.-> B2 end

MPC 优化模型

sp_vision_25planner.cpp 中,MPC 的状态空间模型定义如下:

状态向量: x = [yaw, yaw_vel]
控制输入: u = [yaw_acc]

状态转移:
    yaw_next     = yaw + yaw_vel * dt
    yaw_vel_next = yaw_vel + yaw_acc * dt

用矩阵形式表示:

\[ x_{k+1} = A \cdot x_k + B \cdot u_k \]
\[ A = \begin{bmatrix} 1 & dt \\ 0 & 1 \end{bmatrix}, \quad B = \begin{bmatrix} 0 \\ dt \end{bmatrix} \]

优化目标(Cost Function):

\[ \min_{u_0, \dots, u_{N-1}} \sum_{k=0}^{N-1} \left[ Q \cdot (\text{yaw}_k - \text{yaw}_{\text{ref},k})^2 + R \cdot u_k^2 \right] \]
  • 第一项:云台实际 yaw 与参考轨迹的偏差(希望准)
  • 第二项:控制输入(加速度)的大小(希望平滑)
  • \(Q, R\) 是权重参数,调节"跟踪精度"与"动作平滑"之间的平衡

为什么用 TinyMPC

sp_vision_25 使用 TinyMPC 作为求解器。它是一个专门为嵌入式系统设计的轻量级 MPC 求解器,计算量小,适合在算力有限的嵌入式平台(如 Radxa)上实时运行。相比通用的 OSQP 或 qpOASES 求解器,TinyMPC 在小规模问题上速度快数倍。

提前减速策略

MPC 自瞄相比传统自瞄还有一个重要优势:提前减速(Early Deceleration)

在 RoboMaster 中,目标机器人在切换装甲板时(如小陀螺),目标位置会突然跳变。传统 PID 会跟着跳变猛追,然后又猛回来,导致云台剧烈抖动。

MPC 的做法是在检测到目标可能切换的前一刻,主动让云台减速甚至停止,等新装甲板出现后再平滑跟踪:

graph TD A["目标装甲板预测: 即将切换"] --> B{"装甲板切换?"} B -->|"即将切换"| C["MPC 输出减速指令
云台减速/停止"] B -->|"已切换"| D["MPC 重新规划轨迹
平滑跟踪新装甲板"] C --> D

MPC vs PID 直观对比

特性 PID MPC
信息利用 只看当前误差 看未来 N 步轨迹
目标预测 有(基于运动模型)
输出 角速度 前馈量(速度 + 加速度)
跳变处理 剧烈振荡 提前减速,平滑过渡
开火决策 偏差阈值 轨迹收敛判据,更精确
计算量 极小 较大(需要实时优化)

代码结构

sp_vision_25 中 MPC 规划器的关键文件:

sp_vision_25/
└── tasks/auto_aim/planner/
    ├── planner.cpp           # MPC 轨迹规划主逻辑
    ├── planner.hpp            # Planner 类定义
    └── tinympc/              # TinyMPC 求解器库(admm, tiny_api 等)

12.2 全向感知(Omniperception)

哨兵的特殊需求

在 RoboMaster 比赛中,哨兵机器人需要在己方基地附近巡逻,面对来自 360° 的威胁。单个相机的 FOV(视场角)通常只有 60-90°,无法覆盖全方位。

全向感知模块就是为哨兵设计的:用多个相机覆盖 360°,实现全方位目标感知

graph TD subgraph "全向感知系统架构" C1["相机1 (前)"] --> P1["推理线程1"] C2["相机2 (右)"] --> P2["推理线程2"] C3["相机3 (后)"] --> P3["推理线程3"] C4["相机4 (左)"] --> P4["推理线程4"] P1 --> D["集中决策模块"] P2 --> D P3 --> D P4 --> D D --> G["云台控制"] end

多相机并行推理

每个相机有独立的推理线程,检测结果汇总到决策模块:

// omniperception 核心结构(伪代码)
struct OmniPerception {
    std::vector<Camera> cameras;          // 4个相机
    std::vector<std::thread> threads;     // 4个推理线程
    DecisionMaker decision;               // 集中决策

    void run() {
        for (int i = 0; i < cameras.size(); ++i) {
            threads[i] = std::thread([this, i]() {
                while (running) {
                    cv::Mat frame = cameras[i].grab();
                    auto detections = detector.infer(frame);
                    // 将检测结果转换到统一坐标系
                    auto world_detections = transformToWorld(detections, cameras[i].id);
                    decision.update(cameras[i].id, world_detections);
                }
            });
        }
    }
};

目标优先级排序

全向感知系统中,可能同时检测到多个目标(来自不同相机)。需要一个优先级排序机制来决定先攻击哪个:

优先级 目标类型 原因
1(最高) 工程机器人 抓矿石得分高,必须优先阻止
2 英雄机器人 血量高、威胁大
3 步兵机器人 数量多、持续威胁
4 哨兵/无人机 威胁相对较小

优先级可配置

具体的优先级排序规则根据比赛策略动态调整,一般由决策模块(rm_decision)根据当前比赛态势决定,视觉模块只负责提供检测结果。

delta_angle 计算

多相机场景下,需要将目标的像素坐标转换为相对于云台的转动角度:

\[ \text{delta\_angle} = \text{camera\_offset} + \frac{\text{norm\_x} - 0.5}{\text{image\_width}} \times \text{FOV} \]

其中:

  • camera_offset — 该相机相对于云台的安装偏角
  • norm_x — 目标在图像中的归一化 x 坐标
  • FOV — 该相机的视场角
// 计算云台需要转动的角度
double computeDeltaAngle(const Detection& det, const CameraConfig& cam) {
    // 目标在图像中的归一化坐标 (0~1)
    double norm_x = det.center.x / cam.image_width;
    // 相对于图像中心的偏移,映射到相机 FOV 范围
    double offset_in_fov = (norm_x - 0.5) * cam.fov;
    // 加上相机相对于云台的安装偏角
    return cam.mount_offset + offset_in_fov;
}

12.3 导航决策(rm_decision)

概述

导航决策模块负责哨兵/步兵机器人在比赛中的自主移动行为。它不是视觉模块的直接组成部分,但视觉模块为其提供目标位置信息,是"感知→决策→行动"链路的重要一环。

rm_decision 基于 ROS2 的 Nav2 导航框架,通过 NavigateToPose Action 接口发送导航目标:

// 使用 Nav2 NavigateToPose Action
using NavigateToPose = nav2_msgs::action::NavigateToPose;
using GoalHandleNav = rclcpp_action::ClientGoalHandle<NavigateToPose>;

void sendNavigationGoal(double x, double y, double yaw) {
    auto goal = NavigateToPose::Goal();
    goal.pose.header.frame_id = "map";
    goal.pose.pose.position.x = x;
    goal.pose.pose.position.y = y;
    goal.pose.pose.orientation.z = std::sin(yaw / 2.0);
    goal.pose.pose.orientation.w = std::cos(yaw / 2.0);

    auto send_goal_options = rclcpp_action::Client<NavigateToPose>::SendGoalOptions();
    send_goal_options.result_callback = [this](auto result) {
        if (result.code == rclcpp_action::ResultCode::SUCCEEDED) {
            RCLCPP_INFO(this->get_logger(), "到达目标点");
        }
    };
    nav_client_->async_send_goal(goal, send_goal_options);
}

比赛阶段控制

机器人仅在对战阶段(game_progress == 4)才执行自主导航:

// rm_decision 行为决策(简化)
void DecisionNode::onGameInfo(const rm_interfaces::msg::GameInfo& msg) {
    if (msg.game_progress != 4) {
        // 非对战阶段,不移动
        sendStopCommand();
        return;
    }

    // 对战阶段,根据策略决策
    if (our_hp < retreat_threshold) {
        // 血量低,撤退到补给区
        navigateTo(recovery_point);
    } else if (detectedEnemies > 0) {
        // 发现敌人,进入攻击位置
        navigateTo(attack_positions[nearest_enemy_id]);
    } else {
        // 无目标,巡逻
        patrol();
    }
}

血量阈值策略

stateDiagram-v2 [*] --> Patrol: 进入对战 Patrol --> Attack: 检测到敌人 Patrol --> Patrol: 无敌人,继续巡逻 Attack --> Retreat: 血量 < 30% Attack --> Patrol: 敌人消失 Retreat --> Recover: 到达补给区 Recover --> Patrol: 血量 > 70%

12.4 YOLO 检测器进阶

OpenVINO 推理引擎

在 x86 平台(如工业计算机)上,使用 OpenVINO 可以获得比纯 OpenCV DNN 更好的推理性能:

#include <openvino/openvino.hpp>

class YOLODetector {
public:
    void init(const std::string& model_path) {
        ov::Core core;

        // 读取并编译模型
        auto model = core.read_model(model_path);
        compiled_model_ = core.compile_model(model, "CPU",
            ov::hint::performance_mode(ov::hint::PerformanceMode::LATENCY));

        // 创建推理请求
        infer_request_ = compiled_model_.create_infer_request();
    }

    std::vector<Detection> infer(const cv::Mat& image) {
        // 预处理
        cv::Mat blob = preprocess(image);

        // 设置输入
        auto input_tensor = ov::Tensor(
            compiled_model_.input().get_element_type(),
            compiled_model_.input().get_shape(),
            blob.ptr<float>()
        );
        infer_request_.set_input_tensor(input_tensor);

        // 推理
        infer_request_.infer();

        // 后处理
        auto output = infer_request_.get_output_tensor(0);
        return postprocess(output);
    }

private:
    ov::CompiledModel compiled_model_;
    ov::InferRequest infer_request_;
};

OpenVINO vs TensorRT

特性 OpenVINO TensorRT
平台支持 Intel CPU/GPU/VPU NVIDIA GPU
模型格式 ONNX/IR ONNX/Engine
典型硬件 Intel NUC、工业PC Jetson、RTX显卡
RoboMaster 场景 哨兵/步兵工控机 精英赛 Jetson 平台

YOLO 模型版本选择

sp_vision_25 支持多种 YOLO 版本:

模型 特点 推荐场景
YOLOv5s 轻量,速度快 算力受限的嵌入式平台
YOLOv5m 中等,精度与速度平衡 大多数场景的默认选择
YOLOv8n 新架构,精度更高 需要更高检测精度时
YOLOv11n 最新,精度最优 算力充足,追求极致精度

模型选择建议

刚入门时不要纠结模型选择,用团队提供的默认模型(通常是 YOLOv5m)即可。等到系统跑通后,再根据实际精度和速度需求进行替换。模型大小(s/m/l)直接影响推理速度,在嵌入式平台上尤其要注意。

模型训练流程

如果团队需要针对新兵种训练检测器,完整流程如下:

graph LR A["数据采集"] --> B["数据标注"] B --> C["数据增强"] C --> D["训练"] D --> E["评估"] E -->|"mAP不达标"| F["调参/增数据"] F --> D E -->|"达标"| G["导出ONNX"] G --> H["OpenVINO/TensorRT转换"] H --> I["部署测试"]

Step 1:数据采集

  • 在比赛场地实际拍摄装甲板图片(不同光照、不同角度、不同距离)
  • 每个类别至少 500 张,建议 1000+ 张
  • 注意类别平衡,不要某一类特别多、另一类特别少

Step 2:数据标注

  • 使用 LabelImgCVAT 进行标注
  • 标注装甲板的 bounding box
  • 标注规范:bbox 贴紧装甲板边缘,不要留太多背景

Step 3:数据增强

  • 几何变换:旋转、翻转、缩放
  • 颜色变换:亮度、对比度、色调抖动
  • 马赛克增强(Mosaic):将4张图片拼成一张

Step 4:训练

# 以 YOLOv5 为例
cd yolov5
python train.py \
    --data rm_armor.yaml \
    --weights yolov5m.pt \
    --epochs 300 \
    --batch-size 16 \
    --img-size 640 \
    --device 0

Step 5:导出与部署

# 导出 ONNX
python export.py --weights best.pt --include onnx --img-size 640

# 转换为 OpenVINO IR 格式
mo --input_model best.onnx --output_dir openvino_model/

传统方法角点矫正(use_traditional)

YOLO 检测器提供的是 bounding box,精度有限。在某些场景下,可以启用传统方法(角点检测)来精化装甲板的角点位置:

// YOLO 检测到 bbox 后,用传统方法精化角点
if (use_traditional) {
    cv::Mat roi = image(bbox);  // YOLO 检测区域

    // 1. 颜色过滤:提取灯条区域
    cv::Mat binary;
    cv::cvtColor(roi, binary, cv::COLOR_BGR2GRAY);
    cv::threshold(binary, binary, 200, 255, cv::THRESH_BINARY);

    // 2. 轮廓检测 + 最小外接矩形
    std::vector<std::vector<cv::Point>> contours;
    cv::findContours(binary, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

    // 3. 提取灯条的四个角点(最小外接矩形的顶点)
    for (const auto& contour : contours) {
        cv::RotatedRect rect = cv::minAreaRect(contour);
        cv::Point2f vertices[4];
        rect.points(vertices);
        // 精化后的角点
    }
}

YOLO vs 传统方法

YOLO 负责快速粗定位,传统方法负责精细角点提取,两者结合可以在保持速度的同时提高位姿解算精度。use_traditional 参数可以在配置文件中切换。


12.5 反陀螺算法

什么是小陀螺

小陀螺(Gyro Rotation)是 RoboMaster 比赛中常见的防御策略:机器人原地高速自转,使得对手难以瞄准其装甲板。

graph TD subgraph "小陀螺示意图" R["机器人原地自转"] --> A1["装甲板A面朝前"] A1 --> A2["装甲板B面朝前"] A2 --> A3["装甲板C面朝前"] A3 --> A4["装甲板D面朝前"] A4 --> A1 end

识别对手是否在小陀螺

核心方法是检测目标的角速度

// 反陀螺检测
struct AntiGyroState {
    double angular_velocity;      // 目标角速度 (rad/s)
    bool   is_gyro;               // 是否在小陀螺
    int    current_armor_id;      // 当前看到的装甲板编号
    double coming_angle;          // 当前装甲板的来向角
    double leaving_angle;         // 当前装甲板的离去角

    void update(const TrackingResult& track, double dt) {
        // 通过连续帧的 yaw 变化计算角速度
        angular_velocity = (track.yaw - prev_yaw) / dt;

        // 角速度超过阈值即判断为小陀螺
        is_gyro = std::abs(angular_velocity) > gyro_threshold;  // 通常 > 3 rad/s
    }
};

判断逻辑:

角速度范围 判断 说明
\(< 1\) rad/s 正常行驶 正常击打即可
\(1 - 3\) rad/s 缓慢自转 可能是在转向,需要观察
\(> 3\) rad/s 小陀螺 启用反陀螺策略

击打策略选择

检测到对手小陀螺后,有几种应对策略:

graph TD A["检测到小陀螺"] --> B{"角速度大小?"} B -->|"慢速 (1-3 rad/s)"| C["继续跟踪当前装甲板
等待射击窗口"] B -->|"快速 (>3 rad/s)"| D{"预测装甲板切换?"} D -->|"可以预测"| E["预测下一装甲板位置
提前瞄准,等切换瞬间开火"] D -->|"难以预测"| F["打击大装甲板区域
面积大,命中率高"]

策略 1:等待射击窗口

小陀螺时,装甲板每转一圈会有短暂的正面朝向时刻。在这个窗口期内开火:

  • 优点:命中率高
  • 缺点:射速受限于对手的自转频率

策略 2:预测切换

根据角速度预测下一装甲板的到达时刻和位置:

  • 优点:射击频率更高
  • 缺点:预测不准时会打空

策略 3:打击中心

直接瞄准机器人的几何中心,虽然不是装甲板,但大目标命中率也尚可。

coming/leaving angle 的物理含义

在反陀螺算法中,coming_angleleaving_angle 是两个关键概念:

graph LR subgraph "装甲板运动示意" direction LR A["装甲板从左侧转来
coming_angle"] --> B["装甲板正面朝向相机
最佳射击窗口"] --> C["装甲板向右侧转走
leaving_angle"] end
  • coming_angle:装甲板从侧面转向正面时的角度。如果在 coming 阶段就提前开火,弹丸飞行时间 + 装甲板旋转时间可以对齐,命中率更高。
  • leaving_angle:装甲板从正面转向侧面时的角度。超过此角度就不要再开火,因为装甲板即将被遮挡。
// 基于 coming/leaving angle 的开火决策
bool shouldFire(const AntiGyroState& gyro, const BallisticSolver& ball) {
    if (!gyro.is_gyro) {
        // 非小陀螺,正常判断
        return std::abs(yaw_error) < fire_threshold;
    }

    // 小陀螺模式:预测装甲板的位置
    double t_fly = ball.estimateFlightTime();
    double future_yaw = gyro.current_yaw + gyro.angular_velocity * t_fly;

    // 检查弹丸到达时,装甲板是否在射击窗口内
    bool in_window = isBetween(future_yaw, gyro.coming_angle, gyro.leaving_angle);

    return in_window;
}

反陀螺的局限

反陀螺算法的精度高度依赖于跟踪器的连续性和角速度估计的准确性。当目标短暂被遮挡(如被己方机器人挡住)或装甲板频繁切换时,角速度估计会出错。此时应该回退到保守策略(打击中心或停止射击),而不是盲目预测。


本章小结

graph TD A["进阶主题"] --> B["MPC 轨迹规划"] A --> C["全向感知"] A --> D["导航决策"] A --> E["YOLO 检测器进阶"] A --> F["反陀螺算法"] B --> B1["预测未来轨迹"] B --> B2["前馈输出"] B --> B3["提前减速策略"] C --> C1["多相机并行推理"] C --> C2["360° 覆盖"] C --> C3["目标优先级排序"] D --> D1["Nav2 Action"] D --> D2["血量阈值策略"] D --> D3["仅对战阶段行动"] E --> E1["OpenVINO 推理"] E --> E2["YOLOv5/v8/v11"] E --> E3["传统角点矫正"] F --> F1["角速度检测"] F --> F2["coming/leaving angle"] F --> F3["开火策略选择"]

关键结论:

  1. MPC 将自瞄从"当前帧优化"升级为"轨迹优化",输出前馈量(速度+加速度),开火决策更精确
  2. 全向感知 为哨兵等需要 360° 感知的机器人提供多相机并行推理方案
  3. 导航决策 基于 Nav2 框架,结合血量和敌人信息做自主移动决策
  4. YOLO 进阶 涵盖 OpenVINO 推理、模型训练和传统方法角点矫正
  5. 反陀螺 通过角速度检测和 coming/leaving angle 预测来应对小陀螺策略

建议阅读顺序: 如果你是初次接触这些内容,不必全部理解。先通读一遍建立全局认知,然后根据你在团队中的角色(自瞄、哨兵、决策)深入学习对应章节。MPC 和反陀螺是自瞄进阶的核心,全向感知和导航决策是哨兵/步兵机器人的必备知识。


12.10 导航决策模块深度解析 (RM_Vision_2027)

概述

在之前的 12.3 节中,我们简要介绍了导航决策模块的概念。本节将基于 RM_Vision_2027 仓库的真实代码,逐行解析 rm_decision 节点的完整实现。这个模块是哨兵机器人自主移动行为的"大脑"——它根据比赛状态和自身血量,决定机器人该进攻还是撤退。

graph TD subgraph "rm_decision 模块定位" GC["裁判系统
(game_status / robot_status)"] --> DN["DecisionNode
决策节点"] DN -->|NavigateToPose| Nav2["Nav2 导航框架"] Nav2 --> Robot["机器人底盘执行"] TF["TF2 坐标变换
(map → base_link)"] --> DN end

头文件解析 (decision_node.hpp)

首先看头文件,它定义了决策节点的完整数据结构:

#ifndef RM_DECISION_NODE_HPP_
#define RM_DECISION_NODE_HPP_

#include <string>
#include <rclcpp/rclcpp.hpp>
#include <rclcpp_action/rclcpp_action.hpp>
#include <geometry_msgs/msg/pose_staged.hpp>
#include <nav2_msgs/action/navigate_to_pose.hpp>
#include <tf2_ros/transform_listener.h>
#include <tf2_ros/buffer.h>
#include "auto_aim_interfaces/msg/game_status.hpp"
#include "auto_aim_interfaces/msg/game_robot_status.hpp"

关键依赖说明

  • rclcpp_action — ROS2 Action 客户端库。导航使用 Action(而非 Topic/Service),因为导航是一个长时间执行、可取消的任务,正好适合 Action 的语义
  • nav2_msgs::action::NavigateToPose — Nav2 框架定义的导航 Action 接口,发送目标位姿,Nav2 自动完成路径规划和避障
  • tf2_ros — TF2 坐标变换库。用来查询机器人在 map 坐标系中的当前位置,判断距离目标点多远
  • auto_aim_interfaces — 自定义消息包,包含裁判系统转发的 GameStatusGameRobotStatus
namespace rm_decision
{
    // 目标点结构体:位置 + 朝向(四元数)
    struct TargetPoint
    {
        double x, y, z;        // 位置坐标 (米)
        double qx, qy, qz, qw; // 朝向(四元数表示)
    };

为什么用四元数而不是欧拉角

四元数 (qx, qy, qz, qw) 是 ROS2 中表示姿态的标准方式。相比欧拉角(yaw/pitch/roll),四元数没有万向节锁(Gimbal Lock)问题,且插值更平滑。这里 qw=1.0 表示无旋转(朝向默认方向)。

    class DecisionNode : public rclcpp::Node
    {
    public:
        explicit DecisionNode(const rclcpp::NodeOptions &options);

    private:
        // ====== Nav2 Action 客户端 ======
        using NavigateToPose = nav2_msgs::action::NavigateToPose;
        rclcpp_action::Client<NavigateToPose>::SharedPtr nav_client_;          // 主导航客户端
        rclcpp_action::Client<NavigateToPose>::SharedPtr nav_client_fallback_; // 备用客户端
        std::string nav_action_name_;           // 主 Action 名称
        std::string nav_action_fallback_name_;  // 备用 Action 名称

        // ====== TF2 坐标变换 ======
        std::shared_ptr<tf2_ros::Buffer> tf_buffer_;
        std::shared_ptr<tf2_ros::TransformListener> tf_listener_;

        // ====== ROS2 订阅者 ======
        rclcpp::Subscription<auto_aim_interfaces::msg::GameStatus>::SharedPtr game_status_sub_;
        rclcpp::Subscription<auto_aim_interfaces::msg::GameRobotStatus>::SharedPtr robot_status_sub_;

        // ====== 定时器 ======
        rclcpp::TimerBase::SharedPtr strategy_timer_;  // 策略执行定时器 (1Hz)

        // ====== 回调函数 ======
        void gameStatusCallback(const auto_aim_interfaces::msg::GameStatus::SharedPtr msg);
        void robotStatusCallback(const auto_aim_interfaces::msg::GameRobotStatus::SharedPtr msg);
        void strategyTimerCallback();

        // ====== 辅助函数 ======
        void sendGoal(const std::string &location_name, const TargetPoint &point);
        void cancelCurrentGoal();
        double getDistanceToPoint(const TargetPoint &point);

        // ====== 状态变量 ======
        uint16_t current_time_{0};       // 比赛剩余时间 (秒)
        uint8_t game_progress_{0};       // 比赛阶段 (4=对战)
        uint16_t current_hp_{0};         // 当前血量
        uint16_t max_hp_{600};           // 最大血量 (默认哨兵 600)
        bool is_healing_{false};         // 是否处于补血模式
        bool nav_server_online_{false};  // Nav2 Action Server 是否在线
        std::string last_goal_location_; // 上一次导航目标名称(防抖用)
        double hp_threshold_;            // 血量阈值:低于此值则撤退
        double hp_recovery_ratio_;       // 恢复比例:高于此比例则切回进攻
    };
}

状态变量的含义

这些成员变量构成了决策节点的"记忆":

  • game_progress_ — 裁判系统下发的比赛阶段。只有等于 4(对战阶段)时才行动
  • current_hp_ / max_hp_ — 机器人的当前血量和最大血量
  • is_healing_ — 是否处于撤退补血模式。这是一个双稳态标志:一旦进入补血模式,会持续补血直到血量恢复
  • last_goal_location_ — 记录上一次发送的目标名称,防止重复发送相同目标造成 Nav2 混乱
  • hp_threshold_ / hp_recovery_ratio_ — 可配置参数,通过 launch 文件或参数服务器设置

源文件逐行解析 (decision_node.cpp)

目标点坐标定义

#include "rm_decision/decision_node.hpp"
#include <rclcpp_action/create_client.hpp>
#include <tf2_geometry_msgs/tf2_geometry_msgs.hpp>
#include <cmath>

namespace rm_decision
{
  // ====== 目标点坐标 — 按实际场地修改 ======
  const TargetPoint CENTER_POINT = {4.5, -4.0, 0.0, 0.0, 0.0, 0.0, 1.0}; // 进攻点
  const TargetPoint HOME_POINT = {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0};   // 出生点/补血点

两个目标点的含义

目标点 坐标 (x, y) 含义
CENTER_POINT (4.5, -4.0) 战场中心偏敌方位置,用于进攻
HOME_POINT (0.0, 0.0) 己方出生点/补给区,用于撤退补血

坐标值需要根据当年实际比赛场地修改。(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0) 中后四个值是四元数 (qx=0, qy=0, qz=0, qw=1),表示不改变朝向。

构造函数:参数声明与初始化

  DecisionNode::DecisionNode(const rclcpp::NodeOptions &options)
      : Node("decision_node", options)
  {
    // 声明可配置参数(有默认值)
    this->declare_parameter("hp_threshold", 200.0);       // 血量低于200就撤退
    this->declare_parameter("hp_recovery_ratio", 0.90);   // 恢复到90%就重新进攻
    this->declare_parameter("nav_action_name", "navigate_to_pose");
    this->declare_parameter("nav_action_fallback", "/navigate_to_pose");

    // 读取参数值
    hp_threshold_ = this->get_parameter("hp_threshold").as_double();
    hp_recovery_ratio_ = this->get_parameter("hp_recovery_ratio").as_double();
    nav_action_name_ = this->get_parameter("nav_action_name").as_string();
    nav_action_fallback_name_ = this->get_parameter("nav_action_fallback").as_string();

可配置参数设计

这些参数可以通过 ROS2 的参数系统在 launch 文件中覆盖:

# decision_launch.yaml
decision_node:
  ros__parameters:
    hp_threshold: 200.0      # 血量阈值
    hp_recovery_ratio: 0.90  # 恢复比例
    nav_action_name: "navigate_to_pose"

这种设计的好处是不需要改代码就能调参,方便在不同比赛策略下快速调整。

    // 创建 Nav2 Action 客户端(主 + 备用)
    nav_client_ = rclcpp_action::create_client<NavigateToPose>(this, nav_action_name_);
    if (!nav_action_fallback_name_.empty() && nav_action_fallback_name_ != nav_action_name_)
    {
      nav_client_fallback_ = rclcpp_action::create_client<NavigateToPose>(this, nav_action_fallback_name_);
    }

为什么要备用 Action 客户端

Nav2 的 Action 服务名称可能因配置不同而变化(如 navigate_to_pose vs /navigate_to_pose,有无命名空间前缀)。创建备用客户端可以在主 Action Server 不可用时自动切换,提高系统鲁棒性。

    // TF2 坐标变换:用于查询机器人当前位置
    tf_buffer_ = std::make_shared<tf2_ros::Buffer>(this->get_clock());
    tf_listener_ = std::make_shared<tf2_ros::TransformListener>(*tf_buffer_);

    // 订阅裁判系统消息
    game_status_sub_ = this->create_subscription<auto_aim_interfaces::msg::GameStatus>(
        "game_status", 10,   // topic 名称(launch 中 remap 到 /serial/game_status)
        std::bind(&DecisionNode::gameStatusCallback, this, std::placeholders::_1));

    robot_status_sub_ = this->create_subscription<auto_aim_interfaces::msg::GameRobotStatus>(
        "robot_status", 10,  // topic 名称(launch 中 remap 到 /serial/game_robot_status)
        std::bind(&DecisionNode::robotStatusCallback, this, std::placeholders::_1));

    // 策略定时器:每 1 秒执行一次决策
    strategy_timer_ = this->create_wall_timer(
        std::chrono::seconds(1),
        std::bind(&DecisionNode::strategyTimerCallback, this));

    RCLCPP_INFO(this->get_logger(),
                "Decision node started | hp_threshold=%.0f | recovery=%.0f%% | nav_action=%s",
                hp_threshold_, hp_recovery_ratio_ * 100.0, nav_action_name_.c_str());
  }

订阅者与定时器的协作

订阅者 (game_status_sub_, robot_status_sub_) 被动接收裁判系统推送的消息,更新内部状态变量。定时器 (strategy_timer_) 以 1Hz 频率主动检查状态变量并做出决策。这种"订阅更新 + 定时决策"的分离模式是 ROS2 中的常见设计范式。

消息回调:更新状态

  // 比赛状态回调:更新比赛阶段和剩余时间
  void DecisionNode::gameStatusCallback(
      const auto_aim_interfaces::msg::GameStatus::SharedPtr msg)
  {
    current_time_ = msg->stage_remain_time;  // 比赛剩余秒数
    game_progress_ = msg->game_progress;      // 比赛阶段 (0-5)
  }

  // 机器人状态回调:更新血量
  void DecisionNode::robotStatusCallback(
      const auto_aim_interfaces::msg::GameRobotStatus::SharedPtr msg)
  {
    current_hp_ = msg->remain_hp;  // 当前剩余血量
    max_hp_ = msg->max_hp;         // 最大血量
  }

game_progress 各阶段值

阶段 说明
0 未开始 比赛尚未开始
1 准备阶段 裁判初始化
2 自检阶段 机器人自检
3 5秒倒计时 比赛即将开始
4 对战中 机器人可以自由行动
5 比赛结束 比赛已结束

核心:基于血量的状态机 (strategyTimerCallback)

这是整个决策模块的核心逻辑——一个简单的双状态有限状态机:

  void DecisionNode::strategyTimerCallback()
  {
    // 每 5 秒打印一次状态日志(节省日志量)
    RCLCPP_INFO_THROTTLE(this->get_logger(), *this->get_clock(), 5000,
                         "[Decision] progress=%u | time=%us | HP=%u/%u | healing=%s",
                         game_progress_, current_time_, current_hp_, max_hp_,
                         is_healing_ ? "Y" : "N");

    // ★ 只在对战阶段(game_progress == 4)才行动
    if (game_progress_ != 4)
      return;

    // ★ 低血量检测 → 切换到补血模式
    if (current_hp_ > 0 &&
        current_hp_ < static_cast<uint16_t>(hp_threshold_) &&
        !is_healing_)
    {
      RCLCPP_WARN(this->get_logger(), "HP low (%u < %.0f) -> retreat",
                  current_hp_, hp_threshold_);
      is_healing_ = true;        // 进入补血模式
      last_goal_location_.clear(); // 清除上次目标,允许重新发送
    }

    // ★ 血量恢复检测 → 切换回进攻模式
    if (is_healing_)
    {
      uint16_t recovery_hp = static_cast<uint16_t>(max_hp_ * hp_recovery_ratio_);
      if (current_hp_ >= recovery_hp)
      {
        RCLCPP_INFO(this->get_logger(), "HP recovered (%u >= %u) -> attack",
                    current_hp_, recovery_hp);
        is_healing_ = false;       // 退出补血模式
        last_goal_location_.clear(); // 清除上次目标
      }
    }

    // ★ 执行导航
    if (is_healing_)
    {
      // 补血模式:如果离出生点 > 1米,导航回家
      if (getDistanceToPoint(HOME_POINT) > 1.0)
        sendGoal("home", HOME_POINT);
    }
    else
    {
      // 进攻模式:导航到战场中心
      sendGoal("center", CENTER_POINT);
    }
  }

game_progress==4 的重要性

game_progress != 4 时直接 return,这是硬性约束。原因:

  1. 比赛规则要求机器人在非对战阶段不能移动,否则会被裁判处罚
  2. 在准备和自检阶段乱动可能导致机器人冲出安全区域
  3. 这是与裁判系统串口通信配合的关键:串口节点负责转发裁判系统数据,decision_node 根据数据决定行为

血量阈值设计的双稳态特性

这个状态机有一个精心设计的特性——迟滞(Hysteresis)

  • 进入补血的阈值:HP < 200hp_threshold_
  • 退出补血的阈值:HP >= max_hp * 0.9(即 HP >= 540,哨兵 max_hp=600)

两个阈值差距很大(200 vs 540),这意味着: - 不会在"补血/进攻"之间频繁切换(避免在补给区附近反复徘徊) - 血量必须恢复到足够高才会重新出击,确保生存能力

stateDiagram-v2 [*] --> Attack: game_progress == 4 Attack --> Heal: HP < 200 Heal --> Attack: HP >= max_hp * 90% Attack --> Attack: 继续进攻 CENTER_POINT Heal --> Heal: 继续回家 HOME_POINT note right of Attack 目标: CENTER_POINT (4.5, -4.0) 向敌方区域推进 end note note right of Heal 目标: HOME_POINT (0.0, 0.0) 回出生点补血 距离 < 1m 时停止 end note

距离计算 (getDistanceToPoint)

  double DecisionNode::getDistanceToPoint(const TargetPoint &point)
  {
    try
    {
      // 查询 base_link 在 map 坐标系中的位姿
      auto t = tf_buffer_->lookupTransform("map", "base_link", tf2::TimePointZero);
      // 计算到目标点的欧氏距离
      double dx = point.x - t.transform.translation.x;
      double dy = point.y - t.transform.translation.y;
      return std::sqrt(dx * dx + dy * dy);
    }
    catch (const tf2::TransformException &)
    {
      return 999.0; // TF 不可用时返回大距离(避免触发"到达"逻辑)
    }
  }

TF2 查询的容错设计

lookupTransform 可能因为 TF 树未就绪而抛异常(比如刚启动、定位模块还在初始化)。捕获异常返回 999.0 是一个安全的默认值——返回大距离意味着"认为自己离目标很远",不会错误地触发"已经到达补给区"的判断。

导航目标发送 (sendGoal)

  void DecisionNode::sendGoal(const std::string &location_name, const TargetPoint &point)
  {
    // ★ 防抖:如果目标没变,不重复发送
    if (location_name == last_goal_location_)
      return;

    // 选择可用的 Action 客户端(主优先,备用兜底)
    auto active_client = nav_client_;
    const char *active_action_name = nav_action_name_.c_str();

    if (!active_client->action_server_is_ready())
    {
      if (nav_client_fallback_ && nav_client_fallback_->action_server_is_ready())
      {
        // 主客户端不可用,切换到备用
        active_client = nav_client_fallback_;
        active_action_name = nav_action_fallback_name_.c_str();
      }
      else
      {
        // 两个都不可用,打印警告并退出
        RCLCPP_WARN_THROTTLE(this->get_logger(), *this->get_clock(), 3000,
                             "Nav2 not ready, can't go to %s", location_name.c_str());
        return;
      }
    }

    // 构造 NavigateToPose Goal 消息
    auto goal_msg = NavigateToPose::Goal();
    goal_msg.pose.header.frame_id = "map";     // 在 map 坐标系下
    goal_msg.pose.header.stamp = this->now();  // 时间戳
    goal_msg.pose.pose.position.x = point.x;
    goal_msg.pose.pose.position.y = point.y;
    goal_msg.pose.pose.position.z = point.z;
    goal_msg.pose.pose.orientation.x = point.qx;
    goal_msg.pose.pose.orientation.y = point.qy;
    goal_msg.pose.pose.orientation.z = point.qz;
    goal_msg.pose.pose.orientation.w = point.qw;

    // 异步发送目标(不阻塞)
    active_client->async_send_goal(goal_msg);
    RCLCPP_WARN(this->get_logger(), ">> Nav goal: %s (%.2f, %.2f) via %s",
                location_name.c_str(), point.x, point.y, active_action_name);

    // 记录上次发送的目标名称
    last_goal_location_ = location_name;
  }

防抖机制详解

last_goal_location_ 的作用是防止重复发送相同导航目标

  1. 策略定时器每秒执行一次,但机器人不可能每秒都到达新位置
  2. 如果每秒都发一次 CENTER_POINT,Nav2 会反复重新规划路径,浪费算力
  3. 只有当目标发生变化(从"home"变为"center"或反过来)时才发送新目标
  4. 切换模式时会 last_goal_location_.clear(),允许发送新目标
  // 取消当前导航目标
  void DecisionNode::cancelCurrentGoal()
  {
    if (nav_client_->action_server_is_ready())
      nav_client_->async_cancel_all_goals();
    if (nav_client_fallback_ && nav_client_fallback_->action_server_is_ready())
      nav_client_fallback_->async_cancel_all_goals();
    last_goal_location_.clear();
  }

main 函数

} // namespace rm_decision

int main(int argc, char *argv[])
{
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<rm_decision::DecisionNode>(rclcpp::NodeOptions()));
  rclcpp::shutdown();
  return 0;
}

完整决策流程图

flowchart TD Start["定时器触发 (1Hz)"] --> Check{game_progress == 4 ?} Check -->|否| Skip["跳过本轮
(非对战阶段)"] Check -->|是| HP_Low{HP < 200
且非补血模式?} HP_Low -->|是| EnterHeal["进入补血模式
is_healing_ = true"] HP_Low -->|否| HP_Recover{补血模式中
HP >= max_hp * 90%?} HP_Recover -->|是| EnterAttack["退出补血模式
is_healing_ = false"] HP_Recover -->|否| Execute EnterHeal --> Execute EnterAttack --> Execute Execute{is_healing_?} Execute -->|补血模式| Dist{距离 HOME_POINT > 1m?} Dist -->|是| NavHome["sendGoal: HOME (0,0)"] Dist -->|否| Wait["等待回血"] Execute -->|进攻模式| NavCenter["sendGoal: CENTER (4.5,-4.0)"] NavHome --> Done["本轮完成"] NavCenter --> Done Wait --> Done Skip --> Done

源文件路径

相关代码位于 RM_Vision_2027/rm_decision/ 目录下:

rm_decision/
├── include/rm_decision/
│   └── decision_node.hpp    # 头文件:类定义、成员变量声明
├── src/
│   └── decision_node.cpp    # 源文件:构造函数、回调、策略逻辑
├── CMakeLists.txt
└── package.xml

12.11 能量机关模块概述 (sp_vision_25 auto_buff)

什么是能量机关

在 RoboMaster 比赛中,能量机关(Power Rune,俗称"大符"/"小符")是场地中央一个由 5 个扇叶组成的旋转装置。击中被激活的扇叶(发光扇叶)可以为己方获得额外增益(如增加攻击力、弹丸射速等)。

graph TD subgraph "能量机关示意图" Center["R 中心
(旋转中心)"] F1["扇叶 1"] F2["扇叶 2
★ 发光(activated)"] F3["扇叶 3"] F4["扇叶 4"] F5["扇叶 5"] Center --- F1 Center --- F2 Center --- F3 Center --- F4 Center --- F5 end

能量机关在比赛中的意义

能量机关是比赛中重要的战术目标。成功激活能量机关可以为全队带来增益效果,通常由工程机器人或英雄机器人负责打击。视觉系统需要自动识别并瞄准发光扇叶的中心,实现精确打击。

小符与大符的区别

能量机关有两种模式:

特性 小符 (Small Buff) 大符 (Big Buff)
角速度 恒定 (pi/3 rad/s,约60度/秒) 正弦变化 (a*sin(wt)+C)
旋转方向 顺时针或逆时针 顺时针或逆时针
预测难度 低(匀速运动) 高(需要拟合正弦曲线)
角速度范围 固定 ~1.047 rad/s 0.78 ~ 2.09 rad/s
适用EKF状态数 7维 10维
预测方法 简单 EKF 外推 RANSAC 正弦拟合 + EKF

大符是 RM 视觉的最大挑战之一

大符的角速度按 spd = a * sin(w * t + fi) + (2.09 - a) 变化,其中 a(振幅)在 0.78~1.045 范围,w(角频率)在 1.884~2.000 范围。这意味着扇叶的速度时快时慢,简单的匀速预测会严重偏离真实位置。必须通过长时间观测拟合出正弦曲线的参数,才能准确预测未来位置。

数据结构定义 (buff_type.hpp)

基础枚举类型

namespace auto_buff
{
const int INF = 1000000;

// 能量机关类型
enum PowerRune_type { SMALL, BIG };

// 扇叶状态类型
enum FanBlade_type { _target, _unlight, _light };
//                    目标扇叶   未亮扇叶    已亮扇叶

// 跟踪状态
enum Track_status { TRACK, TEM_LOSE, LOSE };
//                  跟踪中  暂时丢失   彻底丢失

FanBlade_type 三种状态

  • _target — 当前要击打的目标扇叶(发光扇叶中离上次目标最近的那个)
  • _light — 已被检测到发光但不是目标的扇叶
  • _unlight — 未发光的扇叶(5个位置中没有被激活的那些)

FanBlade 类

class FanBlade
{
public:
  cv::Point2f center;               // 扇叶中心像素坐标
  std::vector<cv::Point2f> points;  // 6个关键点(左上角开始逆时针排列)
  double angle, width, height;      // 扇叶的角度、宽度、高度
  FanBlade_type type;               // 扇叶类型 (_target/_unlight/_light)

  // 从 YOLO 关键点构造(检测到的扇叶)
  explicit FanBlade(
    const std::vector<cv::Point2f> & kpt, cv::Point2f keypoints_center, FanBlade_type t);

  // 创建未亮扇叶占位符
  explicit FanBlade(FanBlade_type t);
};

FanBlade 的关键点结构

YOLO11 buff 模型输出 6 个关键点(keypoints),其中: - kpt[0]~kpt[3] — 扇叶四个角点(逆时针排列) - kpt[4] — 扇叶中心(用于 center 字段) - kpt[5] — 扇叶上的参考点(用于计算旋转中心方向)

这些关键点由 YOLO 模型直接回归预测,省去了传统的颜色过滤和轮廓检测步骤。

PowerRune 类

class PowerRune
{
public:
  cv::Point2f r_center;                  // R 中心(旋转中心)像素坐标
  std::vector<FanBlade> fanblades;       // 5个扇叶(从 target 开始顺时针排列)
  int light_num;                         // 当前发光扇叶数量

  // 能量机关中心的 3D 世界坐标 (单位: m)
  Eigen::Vector3d xyz_in_world;          // 直角坐标 (x, y, z)
  Eigen::Vector3d ypr_in_world;          // 姿态角 (yaw, pitch, roll)
  Eigen::Vector3d ypd_in_world;          // 球坐标 (yaw, pitch, distance)

  // 扇叶中心的 3D 世界坐标
  Eigen::Vector3d blade_xyz_in_world;
  Eigen::Vector3d blade_ypd_in_world;

  // 构造函数:从扇叶列表和 R 中心构建 PowerRune
  explicit PowerRune(
    std::vector<FanBlade> & ts, const cv::Point2f r_center,
    std::optional<PowerRune> last_powerrune);

  FanBlade & target() { return fanblades[0]; }; // 获取目标扇叶
  bool is_unsolve() const { return unsolvable_; } // 判断是否无解

private:
  double target_angle_;
  bool unsolvable_ = false;
  double atan_angle(cv::Point2f v) const;  // 计算相对于 R 中心的角度
};

PowerRune 的构造逻辑

PowerRune 的构造函数是理解整个模块的关键。它接收 YOLO 检测到的扇叶列表,通过以下逻辑确定哪个是目标扇叶

  1. 只有一个扇叶 → 它就是目标
  2. 多个扇叶,数量与上次相同 → 找离上次目标最近的扇叶(跟踪连续性)
  3. 多个扇叶,数量比上次多1 → 新亮的那个就是目标(用"最大最小距离"法找离所有旧扇叶最远的那个)

构造完成后,5个扇叶按角度排序并统一为5个位置(未亮的用 _unlight 占位),方便后续的旋转预测。

检测流程概述 (buff_detector.cpp)

Buff_Detector 类结构

class Buff_Detector
{
public:
  Buff_Detector(const std::string & config);

  std::optional<PowerRune> detect_24(cv::Mat & bgr_img);  // 多候选检测(2024+规则)
  std::optional<PowerRune> detect(cv::Mat & bgr_img);     // 单候选检测
  std::optional<PowerRune> detect_debug(cv::Mat & bgr_img, cv::Point2f v);  // 调试模式

private:
  void handle_img(const cv::Mat & bgr_img, cv::Mat & dilated_img);
  cv::Point2f get_r_center(std::vector<FanBlade> & fanblades, cv::Mat & bgr_img);
  void handle_lose();

  YOLO11_BUFF MODE_;              // YOLO11 buff 检测模型
  Track_status status_;           // 跟踪状态
  int lose_;                      // 连续丢失帧数
  double lastlen_;
  std::optional<PowerRune> last_powerrune_;  // 上一帧的 PowerRune(用于跟踪)
};

图像预处理 (handle_img)

void Buff_Detector::handle_img(const cv::Mat & bgr_img, cv::Mat & dilated_img)
{
  // 1. 彩色图转灰度图
  cv::Mat gray_img;
  cv::cvtColor(bgr_img, gray_img, cv::COLOR_BGR2GRAY);

  // 2. 二值化:亮度 > 100 变白,否则变黑
  cv::Mat binary_img;
  cv::threshold(gray_img, binary_img, 100, 255, cv::THRESH_BINARY);

  // 3. 膨胀操作:5x5 矩形核,连接断裂区域
  cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5));
  cv::dilate(binary_img, dilated_img, kernel, cv::Point(-1, -1), 1);
}

handle_img 的作用

这个函数用于 R 中心定位的辅助处理。能量机关的 R 字母在灯条照射下会很亮,通过灰度化→二值化→膨胀,可以提取出 R 字母所在的区域,用于后续的轮廓筛选。

R 中心定位 (get_r_center)

cv::Point2f Buff_Detector::get_r_center(
    std::vector<FanBlade> & fanblades, cv::Mat & bgr_img)
{
  // 1. 估算 R 中心的大概位置
  //    利用每个扇叶的 point5(中心)和 point6(参考点)
  //    沿 (point6 - point5) 方向延伸 1.4 倍距离
  cv::Point2f r_center_t = {0, 0};
  for (auto & fanblade : fanblades) {
    auto point5 = fanblade.points[4];  // 扇叶中心
    auto point6 = fanblade.points[5];  // 参考点
    r_center_t += (point6 - point5) * 1.4 + point5;
  }
  r_center_t /= float(fanblades.size());

  // 2. 在预处理后的图像中,用 mask 裁剪 R 中心附近区域
  cv::Mat dilated_img;
  handle_img(bgr_img, dilated_img);
  double radius = cv::norm(fanblades[0].points[2] - fanblades[0].center) * 0.8;
  cv::Mat mask = cv::Mat::zeros(dilated_img.size(), CV_8U);
  circle(mask, r_center_t, radius, cv::Scalar(255), -1);
  bitwise_and(dilated_img, mask, dilated_img);

  // 3. 找轮廓,通过矩形长宽比 + 距离综合评分找到 R 中心
  std::vector<std::vector<cv::Point>> contours;
  auto r_center = r_center_t;
  cv::findContours(dilated_img, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_NONE);
  double ratio_1 = INF;
  for (auto & it : contours) {
    auto rotated_rect = cv::minAreaRect(it);
    double ratio = /* 长宽比 */ + /* 到估计中心的距离惩罚 */;
    if (ratio < ratio_1) {
      ratio_1 = ratio;
      r_center = rotated_rect.center;
    }
  }
  return r_center;
}

R 中心定位的两步策略

R 中心定位是 buff 检测中最有技巧的部分:

  1. 粗定位:通过扇叶关键点的几何关系推算 R 中心的大致位置(point6 - point5 的延长线交汇处)
  2. 精定位:在粗定位附近区域进行图像处理,找到 R 字母的实际轮廓中心

这种两步策略既利用了 YOLO 的语义信息(扇叶关键点),又利用了传统 CV 的精确性(轮廓定位),比单独使用任一方法都更可靠。

主检测流程 (detect_24)

std::optional<PowerRune> Buff_Detector::detect_24(cv::Mat & bgr_img)
{
  // Step 1: YOLO11 模型推理,获取所有候选扇叶
  std::vector<YOLO11_BUFF::Object> results = MODE_.get_multicandidateboxes(bgr_img);

  // Step 2: 无检测结果时处理丢失逻辑
  if (results.empty()) {
    handle_lose();
    return std::nullopt;
  }

  // Step 3: 将 YOLO 结果转换为 FanBlade 对象
  std::vector<FanBlade> fanblades;
  for (auto & result : results)
    fanblades.emplace_back(FanBlade(result.kpt, result.kpt[4], _light));

  // Step 4: 计算 R 中心
  auto r_center = get_r_center(fanblades, bgr_img);

  // Step 5: 构建 PowerRune(包含目标扇叶识别)
  PowerRune powerrune(fanblades, r_center, last_powerrune_);

  // Step 6: 处理无解情况
  if (powerrune.is_unsolve()) {
    handle_lose();
    return std::nullopt;
  }

  // Step 7: 更新跟踪状态
  status_ = TRACK;
  lose_ = 0;
  std::optional<PowerRune> P;
  P.emplace(powerrune);
  last_powerrune_ = P;
  return P;
}

角速度估计与预测 (buff_target.cpp)

这是能量机关模块最核心也最复杂的部分——预测扇叶未来的旋转角度,以便提前瞄准。

Target 基类

class Target
{
public:
  Target();
  // 获取最新的检测结果,更新 EKF 状态
  virtual void get_target(
    const std::optional<PowerRune> & p,
    std::chrono::steady_clock::time_point & timestamp) = 0;

  // 预测未来 dt 秒后的状态
  virtual void predict(double dt) = 0;

  // 将能量机关坐标系中的点转换到世界坐标系
  Eigen::Vector3d point_buff2world(const Eigen::Vector3d & point_in_buff) const;

  bool is_unsolve() const;
  Eigen::VectorXd ekf_x() const;

protected:
  // EKF 状态矩阵
  Eigen::VectorXd x0_;      // 初始状态
  Eigen::MatrixXd P0_;      // 初始协方差
  Eigen::MatrixXd A_;       // 状态转移矩阵
  Eigen::MatrixXd Q_;       // 过程噪声
  Eigen::MatrixXd H_;       // 观测矩阵
  Eigen::MatrixXd R_;       // 观测噪声
  tools::ExtendedKalmanFilter ekf_;  // 扩展卡尔曼滤波器
  double lasttime_;          // 上次更新时间
  Voter voter;               // 旋转方向投票器
  bool first_in_;            // 是否首次进入
  bool unsolvable_;          // 是否无解
};

Target 的继承设计

Target 是一个抽象基类,定义了角速度跟踪的通用接口。SmallTargetBigTarget 分别实现小符和大符的专用逻辑。这种设计使得上层代码(Aimer)可以统一处理两种模式,只需要持有 Target 基类的引用。

Voter:旋转方向检测器

Voter::Voter() : clockwise_(0) {}

void Voter::vote(const double angle_last, const double angle_now)
{
  if (std::abs(clockwise_) > 50) return;  // 已经确定方向,不再投票
  if (angle_last > angle_now)
    clockwise_--;  // 逆时针
  else
    clockwise_++;  // 顺时针
}

int Voter::clockwise() { return clockwise_ > 0 ? 1 : -1; }

Voter 的投票机制

Voter 通过连续多帧的角度变化方向来判断旋转方向。每帧比较上次角度和当前角度: - 如果角度增大 → 顺时针(clockwise_++) - 如果角度减小 → 逆时针(clockwise_--

当投票数超过 50 时锁定方向不再更新。返回 1(顺时针)或 -1(逆时针),用于 EKF 预测中的方向修正。

SmallTarget:小符跟踪(EKF)

小符的角速度恒定(pi/3 rad/s),EKF 状态向量为 7 维:

// EKF 状态向量 (7维)
// x = [R_yaw, v_R_yaw, R_pitch, R_dis, yaw, roll, spd]
//
// R_yaw    — 能量机关中心的 yaw 角
// v_R_yaw  — R_yaw 的变化率
// R_pitch  — 能量机关中心的 pitch 角
// R_dis    — 到能量机关的距离
// yaw      — 机器人的 yaw
// roll     — 扇叶旋转角度(0~2pi)
// spd      — 角速度 (恒定 = pi/3 rad/s)
SmallTarget::SmallTarget() : Target() {}

void SmallTarget::init(double nowtime, const PowerRune & p)
{
  lasttime_ = nowtime;

  // 状态向量初始化
  x0_.resize(7);
  x0_ << p.ypd_in_world[0],   // R_yaw
         0.0,                  // v_R_yaw
         p.ypd_in_world[1],   // R_pitch
         p.ypd_in_world[2],   // R_dis
         p.ypr_in_world[0],   // yaw
         p.ypr_in_world[2],   // roll (扇叶旋转角)
         SMALL_W * voter.clockwise();  // spd = pi/3 * 方向

  // 初始协方差对角阵(对角线10表示不确定,spd的0.01表示非常确定)
  P0_.resize(7, 7);
  P0_ << 10.0, 0, 0, 0, 0, 0, 0,
         0, 10.0, 0, 0, 0, 0, 0,
         0, 0, 10.0, 0, 0, 0, 0,
         0, 0, 0, 10.0, 0, 0, 0,
         0, 0, 0, 0, 10.0, 0, 0,
         0, 0, 0, 0, 0, 10.0, 0,
         0, 0, 0, 0, 0, 0, 1e-2;  // spd 初始协方差极小(已知)

  ekf_ = tools::ExtendedKalmanFilter(x0_, P0_, x_add);
}

小符 EKF 的关键设计

  1. 状态 spd 的初始协方差为 0.01(非常小),表示一开始就假设角速度是 pi/3,几乎不信任观测到的角速度。这是因为小符角速度恒定,不需要通过观测来学习。
  2. 状态 roll 处理扇叶跳变:扇叶每转过 2*pi/5(72度)就会跳到下一个扇叶位置,代码中通过 ±5 范围搜索来处理这个跳变。
  3. Voter 判断旋转方向:如果 EKF 中的 spd 方向与 Voter 判断相反,会翻转 spd 的符号。

BigTarget:大符跟踪(RANSAC 正弦拟合)

大符的角速度按正弦规律变化,状态向量扩展到 10 维:

// EKF 状态向量 (10维)
// x = [R_yaw, v_R_yaw, R_pitch, R_dis, yaw, roll, spd, a, w, fi]
//
// R_yaw    — 能量机关中心的 yaw 角
// v_R_yaw  — R_yaw 的变化率
// R_pitch  — 能量机关中心的 pitch 角
// R_dis    — 到能量机关的距离
// yaw      — 机器人的 yaw
// roll     — 扇叶旋转角度
// spd      — 当前角速度 (a*sin(wt+fi) + 2.09 - a)
// a        — 正弦振幅 (0.78 ~ 1.045)
// w        — 正弦角频率 (1.884 ~ 2.000)
// fi       — 正弦初相位
BigTarget::BigTarget()
  : Target(),
    spd_fitter_(100, 0.5, 1.884, 2.000)
//            ↑窗口  ↑阈值  ↑w下限  ↑w上限
{}

RansacSineFitter 参数说明

spd_fitter_ 是 RANSAC 正弦拟合器,参数含义:

  • 100 — 拟合窗口大小:使用最近 100 个数据点
  • 0.5 — RANSAC 内点阈值
  • 1.884 — 角频率 w 的下界(1.884 ≈ 2π * 0.3
  • 2.000 — 角频率 w 的上界(2.000 ≈ 2π * 0.318

这些范围来自 RoboMaster 官方规则中对大符参数的限定。

大符的预测函数体现了正弦运动的物理模型:

void BigTarget::predict(double dt)
{
  double spd = fit_spd_;   // 使用 RANSAC 拟合的速度(而非 EKF 直接估计)
  double a = ekf_.x[7];    // 正弦振幅
  double w = ekf_.x[8];    // 正弦角频率
  double fi = ekf_.x[9];   // 正弦初相位
  double t = lasttime_ + dt; // 预测时刻

  // 状态转移矩阵 A (10x10)
  // 关键行:roll = roll + clockwise * dt
  //         spd = a * sin(w*t + fi) + 2.09 - a
  A_ << /* ... 见源码 ... */;

  // 非线性预测函数 f(用于 EKF 的预测步)
  auto f = [&](const Eigen::VectorXd & x) -> Eigen::VectorXd {
    Eigen::VectorXd x_prior = x;
    x_prior[0] = limit_rad(x_prior[0] + dt * x_prior[1]); // R_yaw 更新
    x_prior[5] = limit_rad(x_prior[5] + voter.clockwise() *
      (-a/w * cos(w*t + fi) + a/w * cos(w*lasttime_ + fi) + (2.09-a) * dt)); // roll
    x_prior[6] = a * sin(w * t + fi) + 2.09 - a; // spd: 正弦规律
    return x_prior;
  };

  ekf_.predict(A_, Q_, f);
}

大符的 roll 更新公式

roll 的更新不是简单的 roll += spd * dt,而是对正弦角速度进行积分

\[ \Delta\theta = \int_{t_0}^{t_0+\Delta t} [a \sin(\omega \tau + \phi) + (2.09 - a)] \, d\tau \]

积分结果为:

\[ \Delta\theta = -\frac{a}{\omega}[\cos(\omega t + \phi) - \cos(\omega t_0 + \phi)] + (2.09 - a) \cdot \Delta t \]

这就是代码中 (-a/w * cos(w*t+fi) + a/w * cos(w*lasttime_+fi) + (2.09-a)*dt) 的来源。直接积分比简单欧拉近似精度高很多。

BigTarget::update 中的 RANSAC 拟合

void BigTarget::update(double nowtime, const PowerRune & p)
{
  // ... 标准 EKF 更新(与 SmallTarget 类似)...

  // ★ 关键:将 EKF 估计的速度送入 RANSAC 拟合器
  if (ekf_.x[6] < 2.1 && ekf_.x[6] >= 0)
    spd_fitter_.add_data(nowtime, ekf_.x[6]);

  // 执行正弦拟合
  spd_fitter_.fit();

  // 使用拟合结果预测当前时刻的速度
  fit_spd_ = spd_fitter_.sine_function(
    nowtime,
    spd_fitter_.best_result_.A,      // 拟合得到的振幅
    spd_fitter_.best_result_.omega,  // 拟合得到的角频率
    spd_fitter_.best_result_.phi,    // 拟合得到的初相位
    spd_fitter_.best_result_.C);     // 拟合得到的偏移量
}

双层估计架构

大符的速度估计采用双层架构

  1. 第一层(EKF):实时跟踪当前角速度,对噪声进行平滑。但 EKF 对正弦变化的跟踪有延迟。
  2. 第二层(RANSAC):将 EKF 估计的历史速度收集起来,用 RANSAC 拟合正弦曲线。拟合结果更准确,但需要一定时间的数据积累。

预测时使用 RANSAC 的拟合结果(fit_spd_)而非 EKF 的直接估计(ekf_.x[6]),因为拟合结果能更好地预测未来的正弦变化趋势。

graph LR subgraph "大符双层速度估计" Obs["观测扇叶角度"] --> EKF["EKF
实时平滑"] EKF --> spd["角速度估计
ekf_.x[6]"] spd --> RANSAC["RANSAC 正弦拟合
收集历史数据"] RANSAC --> fit["拟合结果
fit_spd_"] fit --> Predict["预测未来位置"] end

瞄准逻辑 (buff_aimer.cpp)

Aimer 类结构

class Aimer
{
public:
  Aimer(const std::string & config_path);

  // PID 模式瞄准
  io::Command aim(
    Target & target, std::chrono::steady_clock::time_point & timestamp,
    double bullet_speed, bool to_now = true);

  // MPC 模式瞄准
  auto_aim::Plan mpc_aim(
    Target & target, std::chrono::steady_clock::time_point & timestamp,
    io::GimbalState gs, bool to_now = true);

private:
  bool get_send_angle(
    auto_buff::Target & target, const double predict_time, const double bullet_speed,
    const bool to_now, double & yaw, double & pitch);

  double yaw_offset_;      // yaw 角补偿偏移 (配置文件)
  double pitch_offset_;    // pitch 角补偿偏移 (配置文件)
  double fire_gap_time_;   // 射击间隔时间
  double predict_time_;    // 预测时间

  int mistake_count_ = 0;  // 连续角度跳变计数
  bool switch_fanblade_;   // 是否正在切换扇叶
  double last_yaw_ = 0;    // 上次发送的 yaw
  double last_pitch_ = 0;  // 上次发送的 pitch
  std::chrono::steady_clock::time_point last_fire_t_;  // 上次开火时间
};

初始化与配置加载

Aimer::Aimer(const std::string & config_path)
{
  auto yaml = YAML::LoadFile(config_path);
  yaw_offset_ = yaml["yaw_offset"].as<double>() / 57.3;      // 角度转弧度
  pitch_offset_ = yaml["pitch_offset"].as<double>() / 57.3;  // 角度转弧度
  fire_gap_time_ = yaml["fire_gap_time"].as<double>();        // 射击间隔
  predict_time_ = yaml["predict_time"].as<double>();          // 预测提前量

  last_fire_t_ = std::chrono::steady_clock::now();
}

核心瞄准函数 (get_send_angle)

这是瞄准逻辑的核心——将预测的扇叶位置转换为云台的 yaw/pitch 角度:

bool Aimer::get_send_angle(
    auto_buff::Target & target, const double predict_time, const double bullet_speed,
    const bool to_now, double & yaw, double & pitch)
{
  // Step 1: 预测目标在 predict_time 秒后的位置
  target.predict(predict_time);
  angle = target.ekf_x()[5];  // 记录当前旋转角度

  // Step 2: 计算预测位置的 3D 世界坐标
  // (0.0, 0.0, 0.7) 是扇叶中心相对于 R 中心的偏移(高度 0.7m)
  auto aim_in_world = target.point_buff2world(Eigen::Vector3d(0.0, 0.0, 0.7));
  double d = sqrt(aim_in_world[0]*aim_in_world[0] + aim_in_world[1]*aim_in_world[1]); // 水平距离
  double h = aim_in_world[2];  // 高度

  // Step 3: 第一次弹道解算(估算飞行时间)
  tools::Trajectory trajectory0(bullet_speed, d, h);
  if (trajectory0.unsolvable) return false;

  // Step 4: 用飞行时间再次预测目标位置
  target.predict(trajectory0.fly_time);
  aim_in_world = target.point_buff2world(Eigen::Vector3d(0.0, 0.0, 0.7));
  d = fsqrt(aim_in_world[0]*aim_in_world[0] + aim_in_world[1]*aim_in_world[1]);
  h = aim_in_world[2];

  // Step 5: 第二次弹道解算(收敛迭代)
  tools::Trajectory trajectory1(bullet_speed, d, h);
  if (trajectory1.unsolvable) return false;

  // Step 6: 检查两次飞行时间是否收敛
  auto time_error = trajectory1.fly_time - trajectory0.fly_time;
  if (std::abs(time_error) > 0.01) return false;  // 不收敛,放弃

  // Step 7: 计算最终发送角度
  yaw = std::atan2(aim_in_world[1], aim_in_world[0]) + yaw_offset_;
  pitch = trajectory1.pitch + pitch_offset_;
  return true;
}

迭代弹道解算的必要性

瞄准需要两次弹道解算,这是一个不动点迭代过程:

  1. 第一次:用当前距离估算飞行时间 t_fly_0
  2. 预测:用 t_fly_0 预测目标在弹丸到达时的位置
  3. 第二次:用新位置重新计算飞行时间 t_fly_1
  4. 收敛检查:如果 |t_fly_1 - t_fly_0| < 0.01s,认为已收敛

这样做是因为弹丸飞行时间取决于距离,而距离又取决于目标在弹丸到达时的位置——两者是耦合的,需要迭代求解。

aim 函数:开火决策

io::Command Aimer::aim(
    auto_buff::Target & target, std::chrono::steady_clock::time_point & timestamp,
    double bullet_speed, bool to_now)
{
  io::Command command = {false, false, 0, 0};
  if (target.is_unsolve()) return command;

  // 默认子弹速度 24 m/s
  if (bullet_speed < 10) bullet_speed = 24;

  auto now = std::chrono::steady_clock::now();
  auto detect_now_gap = tools::delta_time(now, timestamp);
  auto future = to_now ? (detect_now_gap + predict_time_) : 0.1 + predict_time_;

  double yaw, pitch;
  if (get_send_angle(target, future, bullet_speed, to_now, yaw, pitch)) {
    command.yaw = yaw;
    command.pitch = -pitch;  // 世界坐标系 pitch 向上为负

    // ★ 开火安全检查:检测角度跳变
    if (mistake_count_ > 3) {
      // 连续3次跳变 → 强制切扇叶
      switch_fanblade_ = true;
      mistake_count_ = 0;
      command.control = true;
    } else if (std::abs(last_yaw_ - yaw) > 5/57.3 || std::abs(last_pitch_ - pitch) > 5/57.3) {
      // 角度跳变 > 5° → 暂不开火
      switch_fanblade_ = true;
      mistake_count_++;
      command.control = false;
    } else {
      // 角度稳定 → 允许开火
      switch_fanblade_ = false;
      mistake_count_ = 0;
      command.control = true;
    }
    last_yaw_ = yaw;
    last_pitch_ = pitch;
  }

  // ★ 射击间隔控制
  if (switch_fanblade_) {
    command.shoot = false;      // 正在切换扇叶,不开火
    last_fire_t_ = now;
  } else if (tools::delta_time(now, last_fire_t_) > fire_gap_time_) {
    command.shoot = true;       // 间隔足够,可以开火
    last_fire_t_ = now;
  }

  return command;
}

扇叶切换保护机制

在能量机关旋转过程中,目标扇叶会跳变(从一个扇叶切换到下一个)。跳变瞬间,预测角度可能突然变化很大。代码通过以下逻辑保护:

  • 如果角度跳变 > 5度 → 暂不开火,计数器 +1
  • 如果连续 3 次跳变 → 判定为扇叶切换,强制等待
  • 只有角度稳定时才允许开火

这避免了在扇叶切换瞬间误射击。

YOLO11 Buff 检测模型概述

yolo11_buff 是专门用于能量机关检测的 YOLO11 模型。与装甲板检测不同,buff 模型需要输出关键点(而非简单的 bounding box):

graph LR subgraph "YOLO11 Buff 模型输入输出" Input["输入: 640x640 彩色图像"] --> Model["YOLO11 Buff ONNX 模型"] Model --> Output1["Bounding Box
扇叶位置"] Model --> Output2["6个关键点
4角 + 中心 + 参考点"] Model --> Output3["置信度
发光/未发光"] end

模型的关键输出是每个扇叶的 6 个关键点(keypoints),这些关键点:

  1. 4 个角点 (kpt[0]~kpt[3]) — 扇叶的四边形顶点,用于 PnP 位姿解算
  2. 中心点 (kpt[4]) — 扇叶几何中心,用于角度计算和 R 中心定位
  3. 参考点 (kpt[5]) — 扇叶上的特定标记点,用于确定 R 中心方向
// Buff_Detector 中使用 YOLO11 模型的方式
// 多候选模式(2024+规则)
std::vector<YOLO11_BUFF::Object> results = MODE_.get_multicandidateboxes(bgr_img);

// 单候选模式
std::vector<YOLO11_BUFF::Object> results = MODE_.get_onecandidatebox(bgr_img);

// 每个 Object 包含:
// - bbox: 扇叶的 bounding box
// - kpt: 6 个关键点的像素坐标
// - score: 检测置信度

为什么用关键点而不是简单的 bbox

能量机关的扇叶是不规则形状,单纯的 bbox 无法提供足够的几何信息。6 个关键点可以:

  • 精确确定扇叶的姿态(旋转角度)
  • 通过 kpt[4](中心)直接计算扇叶相对于 R 中心的角度
  • 通过 kpt[5](参考点)辅助定位 R 中心
  • 用于 PnP 位姿解算得到扇叶的 3D 位置

能量机关处理完整流水线

flowchart TD subgraph "Step 1: 检测" Cam["相机图像"] --> YOLO["YOLO11 Buff 模型推理"] YOLO --> KPT["6个关键点 × N个扇叶"] KPT --> FB["FanBlade 对象构造"] end subgraph "Step 2: 定位" FB --> RC["get_r_center()
计算 R 中心位置"] IMG["原始图像"] --> PROC["handle_img()
灰度化 → 二值化 → 膨胀"] PROC --> RC end subgraph "Step 3: 关联" RC --> PR["PowerRune 构造
目标扇叶识别"] FB --> PR LAST["上一帧 PowerRune"] --> PR PR --> TARGET["target() 获取目标扇叶"] end subgraph "Step 4: 3D 位姿解算" TARGET --> PnP["PnP 解算
2D关键点 → 3D世界坐标"] PnP --> YPD["ypd_in_world
球坐标表示"] PNP --> BLADE["blade_ypd_in_world
扇叶球坐标"] end subgraph "Step 5: 状态估计 (EKF)" YPD --> INIT{"首次?"} INIT -->|是| INIT_EKF["EKF 初始化
SmallTarget: 7维
BigTarget: 10维"] INIT -->|否| UPD["EKF 更新"] INIT_EKF --> UPD UPD --> EKF_X["EKF 状态 x
(位置/角度/速度)"] end subgraph "Step 6: 速度拟合 (仅大符)" EKF_X --> FIT{"大符?"} FIT -->|是| RANSAC["RANSAC 正弦拟合
收集历史速度
拟合 a·sin(wt+fi)+C"] RANSAC --> FIT_SPD["fit_spd_"] FIT -->|否| SMALL_SPD["直接使用
恒定速度 pi/3"] end subgraph "Step 7: 瞄准" EKF_X --> PRED["predict(future_time)
预测未来位置"] FIT_SPD --> PRED SMALL_SPD --> PRED PRED --> WORLD["point_buff2world()
转换到世界坐标"] WORLD --> BALL["弹道解算
2次迭代"] BALL --> ANGLE["yaw + pitch"] end subgraph "Step 8: 开火决策" ANGLE --> JUMP{"角度跳变 > 5°?"} JUMP -->|是| NO_FIRE["不开火
计数器+1"] JUMP -->|否| INTERVAL{"射击间隔
足够?"} INTERVAL -->|是| FIRE["开火!"] INTERVAL -->|否| WAIT["等待"] end

源文件路径

能量机关相关代码位于 sp_vision_25/tasks/auto_buff/ 目录下:

sp_vision_25/tasks/auto_buff/
├── buff_type.hpp          # 数据类型定义 (FanBlade, PowerRune, 枚举)
├── buff_type.cpp          # FanBlade/PowerRune 实现 (目标识别逻辑)
├── buff_detector.hpp      # 检测器头文件 (Buff_Detector 类)
├── buff_detector.cpp      # 检测器实现 (YOLO推理 + R中心定位)
├── buff_target.hpp        # 目标跟踪头文件 (Target/SmallTarget/BigTarget)
├── buff_target.cpp        # 目标跟踪实现 (EKF + RANSAC)
├── buff_aimer.hpp         # 瞄准器头文件 (Aimer 类)
├── buff_aimer.cpp         # 瞄准器实现 (弹道解算 + 开火决策)
├── yolo11_buff.hpp        # YOLO11 buff 检测模型封装
└── yolo11_buff.cpp        # 模型推理实现

学习建议

能量机关模块的复杂度远高于常规自瞄。建议按以下顺序阅读:

  1. 先理解 buff_type.hpp/cpp — 理解数据结构和扇叶关联逻辑
  2. 再看 buff_detector.cpp — 理解 YOLO 推理 + R 中心定位
  3. 然后是 buff_target.cpp — 这是最复杂的部分,重点理解 EKF 状态和 RANSAC 拟合
  4. 最后看 buff_aimer.cpp — 理解弹道解算和开火决策

小符的代码相对简单(7维EKF + 恒定速度),建议先读懂小符再看大符。大符的核心创新在于 RANSAC 正弦拟合——它把一个"预测问题"转化成了"参数估计问题"。