跳转至

第 10 章:弹道解算与瞄准

从"看到"到"打中"的最后一步

在前面的章节中,我们已经完成了:

前置知识

本章涉及弹道物理和瞄准决策。遇到不懂的概念参考: - 📖 ch11 标定系统(理解 camera_matrix_) - 📖 ch08 目标跟踪(理解 EKF 状态)

  • 检测:从图像中找到装甲板(第 7 章)
  • 跟踪:用 EKF 追踪目标运动状态(第 8 章)
  • 位姿解算:用 PnP 得到目标在三维空间中的位置(第 9 章)

现在我们手里有了目标的三维坐标 \((x, y, z)\)。下一步很简单——打中它

但事情没那么简单。子弹不是激光,从枪口飞到目标需要时间,期间会受重力和空气阻力影响。如果直接把枪口对准目标,子弹一定会打偏。

本章要解决的问题:

graph LR A["目标 3D 坐标
(x, y, z)"] --> B["弹道解算
计算 pitch 补偿角"] B --> C["瞄准点选择
选择打哪块装甲板"] C --> D["飞行时间迭代
补偿目标移动"] D --> E["射击决策
判断能否开火"] E --> F["发送给电控
yaw + pitch 指令"] style A fill:#4a9eff,color:#fff style F fill:#f7a72b,color:#fff

10.1 为什么需要弹道解算

子弹不是激光

想象一下你拿着一把激光笔,对准目标一照,光线瞬间到达——这就是"直射"。但 RoboMaster 的子弹是物理弹丸,它有初速度(约 25~30 m/s),离开枪口后会受到:

  1. 重力:子弹始终受到向下的重力加速度 \(g \approx 9.8 \, \text{m/s}^2\)
  2. 空气阻力:子弹飞行时受到与速度方向相反的阻力

这导致子弹的轨迹是一条抛物线(或更精确地说是一条受阻力影响的曲线),而不是直线。

弹道偏移有多严重?

我们来算一下。假设子弹初速度 \(v_0 = 28 \, \text{m/s}\),目标距离 \(d = 5 \, \text{m}\)

子弹飞行时间(忽略阻力):

\[ t = \frac{d}{v_0} = \frac{5}{28} \approx 0.18 \, \text{s} \]

重力导致的下落距离:

\[ \Delta z = \frac{1}{2} g t^2 = \frac{1}{2} \times 9.8 \times 0.18^2 \approx 0.16 \, \text{m} = 16 \, \text{cm} \]

16 厘米! 装甲板的高度大约只有 13 厘米,如果不做弹道补偿,5 米外的子弹会直接从装甲板下方飞过。

距离 (m) 飞行时间 (s) 重力下落 (cm) 装甲板高度 (cm) 是否需要补偿
2 0.07 2.5 13 可选
3 0.11 5.7 13 需要
5 0.18 15.7 13 必须
8 0.29 40.3 13 必须

关键结论:超过 3 米就必须做弹道补偿,否则子弹会从装甲板下面飞过去。

弹道解算的核心任务

弹道解算要做的事情可以用一句话概括:

给定目标的三维坐标,计算枪口应该往上抬多少角度(pitch 补偿角),才能让子弹命中目标。

graph TD subgraph "不补偿" A1["枪口水平瞄准"] --> B1["子弹沿抛物线下落"] B1 --> C1["打在目标下方 ❌"] end subgraph "有补偿" A2["枪口上抬 Δθ"] --> B2["子弹沿抛物线飞行"] B2 --> C2["命中目标 ✅"] end style C1 fill:#ff6b6b,color:#fff style C2 fill:#51cf66,color:#fff

10.2 抛物线弹道模型(sp_vision_25 实现)

模型假设

最简单的弹道模型做了一个大胆假设:忽略空气阻力

这意味着子弹只受重力作用,轨迹是一条标准抛物线。同济大学的 sp_vision_25 仓库就采用这种模型。

物理公式

在忽略空气阻力的条件下,子弹的运动可以分解为两个方向:

水平方向(匀速运动):

\[ x = v_0 \cos(\theta) \cdot t \]

垂直方向(匀加速运动):

\[ z = v_0 \sin(\theta) \cdot t - \frac{1}{2} g t^2 \]

其中:

符号 含义 典型值
\(v_0\) 子弹初速度 25~30 m/s
\(\theta\) 发射角(相对水平面的仰角) 待求
\(t\) 飞行时间 待求
\(g\) 重力加速度 9.7833 m/s²

求解思路

已知目标距离 \(d\) 和高度差 \(h\),求发射角 \(\theta\)

从水平方程解出飞行时间:

\[ t = \frac{d}{v_0 \cos(\theta)} \]

代入垂直方程:

\[ h = v_0 \sin(\theta) \cdot \frac{d}{v_0 \cos(\theta)} - \frac{1}{2} g \left( \frac{d}{v_0 \cos(\theta)} \right)^2 \]

化简:

\[ h = d \tan(\theta) - \frac{g d^2}{2 v_0^2 \cos^2(\theta)} \]

利用 \(\frac{1}{\cos^2(\theta)} = 1 + \tan^2(\theta)\),令 \(u = \tan(\theta)\)

\[ h = d \cdot u - \frac{g d^2}{2 v_0^2} (1 + u^2) \]

整理成关于 \(u\) 的二次方程:

\[ \frac{g d^2}{2 v_0^2} u^2 - d \cdot u + \left( h + \frac{g d^2}{2 v_0^2} \right) = 0 \]

用求根公式解出 \(\tan(\theta)\),再求反正切得到发射角 \(\theta\)。注意二次方程有两个解(高弹道和低弹道),代码中选择飞行时间更短的那个(低弹道)。

完整代码:sp_vision_25 trajectory.cpp

以下代码来自 sp_vision_25/tools/trajectory.cpp,是同济大学 SuperPower 战队的实际弹道解算实现。

#include "trajectory.hpp"

#include <cmath>

namespace tools
{
constexpr double g = 9.7833;  // 重力加速度(注意:不是 9.8,而是实测值)

Trajectory::Trajectory(const double v0, const double d, const double h)
{
  // 构造二次方程的三个系数:a·u² + b·u + c = 0
  // 其中 u = tan(θ)
  auto a = g * d * d / (2 * v0 * v0);   // a = gd²/(2v₀²)
  auto b = -d;                            // b = -d
  auto c = a + h;                         // c = gd²/(2v₀²) + h

  // 计算判别式
  auto delta = b * b - 4 * a * c;

  if (delta < 0) {
    // 判别式 < 0:无实数解,说明目标太远,子弹打不到
    unsolvable = true;
    return;
  }

  unsolvable = false;

  // 用求根公式解出两个 tan(θ) 的值
  auto tan_pitch_1 = (-b + std::sqrt(delta)) / (2 * a);  // 高弹道
  auto tan_pitch_2 = (-b - std::sqrt(delta)) / (2 * a);  // 低弹道

  // 转换为弧度角
  auto pitch_1 = std::atan(tan_pitch_1);
  auto pitch_2 = std::atan(tan_pitch_2);

  // 计算两条弹道各自的飞行时间
  // t = d / (v₀ · cos(θ))
  auto t_1 = d / (v0 * std::cos(pitch_1));
  auto t_2 = d / (v0 * std::cos(pitch_2));

  // 选择飞行时间更短的那个(低弹道)
  // 低弹道子弹更快到达目标,受风和其他干扰更小
  pitch = (t_1 < t_2) ? pitch_1 : pitch_2;
  fly_time = (t_1 < t_2) ? t_1 : t_2;
}

}  // namespace tools

逐行解读

  • constexpr double g = 9.7833:这里用的重力加速度是 9.7833 而不是标准的 9.8,这是因为 RM 比赛场地的纬度和海拔不同,实测值略有差异。同济在上海,纬度较高处重力略小。
  • auto a = g * d * d / (2 * v0 * v0):这就是公式中的 \(\frac{gd^2}{2v_0^2}\),是关于 \(\tan(\theta)\) 的二次方程的二次项系数。
  • auto b = -d:一次项系数。
  • auto c = a + h:常数项,注意这里 \(c = a + h\) 是因为将 \(\frac{gd^2}{2v_0^2}\) 合并到了一起。
  • delta < 0:判别式为负意味着抛物线的最大射程不够远,子弹无法到达目标高度。
  • 两个解分别对应高弹道(pitch_1,弹道弯曲大)和低弹道(pitch_2,弹道平直)。选择飞行时间短的低弹道更实用——子弹在空中时间短,受干扰小。
  • fly_time 的计算 d / (v0 * cos(pitch)) 就是水平方向匀速运动的时间公式。

对应的头文件:trajectory.hpp

#ifndef TOOLS__TRAJECTORY_HPP
#define TOOLS__TRAJECTORY_HPP

namespace tools
{
struct Trajectory
{
  bool unsolvable;  // 弹道是否无法求解(目标超出射程)
  double fly_time;  // 子弹飞行时间(秒)
  double pitch;     // pitch 补偿角(弧度,抬头为正)

  // 不考虑空气阻力的抛物线弹道模型
  // v0 子弹初速度大小,单位:m/s
  // d  目标水平距离,单位:m
  // h  目标竖直高度,单位:m
  Trajectory(const double v0, const double d, const double h);
};

}  // namespace tools

#endif  // TOOLS__TRAJECTORY_HPP

逐行解读

  • 整个 Trajectory 被设计为一个结构体,构造函数里直接完成所有计算——这是一种常见的"计算即构造"模式。
  • unsolvable 标志告诉调用者:这个目标打不到,不要继续计算了。
  • pitch 字段以弧度为单位,正值表示枪口需要往上抬。
  • fly_time 供后续的飞行时间迭代使用。

优缺点

优点 缺点
计算量极小(纯数学公式) 忽略空气阻力,远距离不准确
代码简洁,30 行搞定 高弹道/低弹道需要手动选择
适合近距离(5m 以内) 真实弹道偏大时会低估补偿

适用场景:如果你的机器人主要在近距离作战,这个模型完全够用。


10.3 空气阻力弹道模型(RM_Vision_2027 实现)

为什么需要更精确的模型

在实际比赛中,子弹速度在飞行过程中会因空气阻力而衰减。忽略阻力的简化模型在 5 米以上会明显低估弹道下落,导致打高或者打低。

RM_Vision_2027 仓库采用了一种考虑空气阻力的单方向空气阻力模型,并用数值迭代求解 pitch 补偿角。

空气阻力公式

空气阻力的大小为:

\[ F_{\text{drag}} = \frac{1}{2} \rho C_d A v^2 \]
符号 含义 典型值
\(\rho\) 空气密度 1.225 kg/m³
\(C_d\) 阻力系数(与弹丸形状有关) 0.47(球形弹丸)
\(A\) 弹丸截面积 \(\pi r^2\),17mm 弹丸约 \(2.27 \times 10^{-4}\)
\(v\) 弹丸当前速度 随时间衰减

RM_Vision_2027 的方案中,弹道系数被简化为一个参数 \(k = 0.092\),定义为:

\[ k = \frac{\rho C_d A}{2m} \]

其中 \(m\) 是弹丸质量。这个参数可以通过实验标定。

空气阻力公式的数学推导

代码中实际使用的阻力模型是二次阻力(与速度平方成正比),而非注释中暗示的线性阻力。以下是从物理方程到代码公式的完整推导。

第一步:建立微分方程

水平方向的运动方程(只考虑水平阻力,忽略垂直方向对水平的耦合):

\[ \frac{dv_x}{dt} = -k v_x^2 \]

其中 \(v_x(0) = v_0 \cos\theta\)。这是一个可分离变量的常微分方程。

第二步:分离变量并积分

\[ \frac{dv_x}{v_x^2} = -k \, dt \]
\[ \int_{v_0\cos\theta}^{v_x} \frac{du}{u^2} = -k \int_0^t d\tau \]
\[ \left[-\frac{1}{u}\right]_{v_0\cos\theta}^{v_x} = -kt \]
\[ -\frac{1}{v_x} + \frac{1}{v_0\cos\theta} = -kt \]

第三步:解出速度

\[ v_x(t) = \frac{v_0 \cos\theta}{1 + k v_0 \cos\theta \cdot t} \]

注意:这是双曲线衰减\(1/(1+at)\)),而非注释中写的指数衰减(\(e^{-kt}\))。两者在小 \(kt\) 时接近,但在大距离下差异明显。

第四步:积分得到距离

\[ s = \int_0^t v_x(\tau) \, d\tau = \int_0^t \frac{v_0 \cos\theta}{1 + k v_0 \cos\theta \cdot \tau} \, d\tau \]

\(u = 1 + k v_0 \cos\theta \cdot \tau\),则 \(du = k v_0 \cos\theta \, d\tau\)

\[ s = \frac{1}{k} \int_1^{1+k v_0 \cos\theta \cdot t} \frac{du}{u} = \frac{1}{k} \ln(1 + k v_0 \cos\theta \cdot t) \]

第五步:反解飞行时间

\[ ks = \ln(1 + k v_0 \cos\theta \cdot t) \]
\[ e^{ks} = 1 + k v_0 \cos\theta \cdot t \]
\[ t = \frac{e^{ks} - 1}{k v_0 \cos\theta} \]

这正是代码中的公式:

t = (float)((exp(st.k * s) - 1) / (st.k * v * cos(angle)));

垂直方向(不受水平阻力影响,仍用匀加速运动):

\[ z = v_0 \sin\theta \cdot t - \frac{1}{2} g t^2 \]

即代码中的:

z = (float)(v * sin(angle) * t - GRAVITY * t * t / 2);

注意代码注释的错误:原代码注释写的是"水平速度衰减:v_x(t) = v₀·cos(θ)·e^(-kt)",这是线性阻力\(dv/dt = -kv\))的解。但实际公式 \((e^{ks}-1)/(kv_0\cos\theta)\) 来自二次阻力\(dv/dt = -kv^2\))的推导。两者完全不同——线性阻力得到指数衰减,二次阻力得到双曲线衰减。

完整代码:RM_Vision_2027 solve_trajectory.cpp

以下代码来自 RM_Vision_2027/rm_serial_driver/src/solve_trajectory.cpp,包含弹道模型、pitch 补偿迭代、飞行时间迭代和完整的装甲板选择逻辑。

/*
@brief: 弹道解算 - improved with sp_vision_25 aim point selection logic
@author: CodeAlan (original), adapted from TongjiSuperPower sp_vision_25
*/
// 近点只考虑水平方向的空气阻力

#include "rm_serial_driver/solve_trajectory.hpp"

#include <algorithm>
#include <cmath>
#include <vector>

struct tar_pos tar_position[4];  // 最多只有四块装甲板
float t = 0.6f;                  // 飞行时间(全局变量,用于迭代)
struct SolveTrajectoryParams st;

// ── 单方向空气阻力弹道模型 ──

/*
@brief 单方向空气阻力弹道模型
@param s:m 距离
@param v:m/s 速度
@param angle:rad 角度
@return z:m 给定条件下子弹到达水平距离s时的高度
*/
float monoDirectionalAirResistanceModel(float s, float v, float angle)
{
  float z;
  // t 为给定 v 与 angle 时,子弹飞过水平距离 s 所需的时间
  // 基于二次阻力模型 dv/dt = -k*v^2 的解析解
  // 速度:v_x(t) = v₀·cos(θ) / (1 + k·v₀·cos(θ)·t)
  // 距离:s = ln(1 + k·v₀·cos(θ)·t) / k
  // 反解时间:t = (e^(ks) - 1) / (k·v₀·cos(θ))
  t = (float)((exp(st.k * s) - 1) / (st.k * v * cos(angle)));
  if (t < 0) {
    t = 0;
    return 0;
  }
  // z 为给定 v 与 angle 时的高度(垂直方向仍用匀加速运动)
  z = (float)(v * sin(angle) * t - GRAVITY * t * t / 2);
  return z;
}

/*
@brief pitch轴弹道补偿(迭代求解)
@param s:m 水平距离
@param z:m 目标高度
@param v:m/s 子弹速度
@return angle_pitch:rad 需要的 pitch 补偿角
*/
float pitchTrajectoryCompensation(float s, float z, float v)
{
  float z_temp, z_actual, dz;
  float angle_pitch;
  int i = 0;
  z_temp = z;

  // 迭代求解:最多 20 次
  for (i = 0; i < 20; i++) {
    // 用当前猜测的高度 z_temp 计算 pitch 角
    angle_pitch = atan2(z_temp, s);  // rad

    // 用弹道模型模拟:以这个 pitch 角发射,子弹实际到达的高度
    z_actual = monoDirectionalAirResistanceModel(s, v, angle_pitch);
    if (z_actual == 0) {
      angle_pitch = 0;
      break;
    }

    // 计算高度偏差,用 0.3 的阻尼系数防止振荡
    dz = 0.3 * (z - z_actual);
    z_temp = z_temp + dz;

    // 收敛条件:高度偏差小于 0.01mm
    if (fabsf(dz) < 0.00001) {
      break;
    }
  }
  return angle_pitch;
}

/*
@brief 迭代求解飞行时间(用于打前兼顾发弹延时)
@param pitch: 传入传出的 pitch 角
@param yaw: 传入传出的 yaw 角
@param aim_x, aim_y, aim_z: 瞄准点坐标
*/
void iterativeFlyTimeCompensation(
  float * pitch, float * yaw, float * aim_x, float * aim_y, float * aim_z)
{
  constexpr int max_iter = 10;
  float prev_fly_time = 0;
  float timeDelay = st.bias_time / 1000.0;

  for (int iter = 0; iter < max_iter; ++iter) {
    // 预测目标在 total_delay 时刻后的位置
    float total_delay = timeDelay + prev_fly_time;
    st.tar_yaw += st.v_yaw * total_delay;

    float temp_aim_z = tar_position[0].z + st.vzw * total_delay;
    float temp_aim_x = tar_position[0].x + st.vxw * total_delay;
    float temp_aim_y = tar_position[0].y + st.vyw * total_delay;

    // 计算新的 pitch 补偿角
    float temp_pitch = pitchTrajectoryCompensation(
      sqrt(temp_aim_x * temp_aim_x + temp_aim_y * temp_aim_y) - st.s_bias,
      temp_aim_z + st.z_bias, st.current_v);

    if (temp_pitch == 0) break;

    // 根据新的 pitch 重新计算飞行时间
    float fly_time = (float)((exp(st.k * sqrt(temp_aim_x * temp_aim_x + temp_aim_y * temp_aim_y)) - 1) /
                             (st.k * st.current_v * cos(temp_pitch)));

    // 收敛条件:飞行时间变化 < 1ms
    if (fabsf(fly_time - prev_fly_time) < 0.001f) break;
    prev_fly_time = fly_time;
  }
}

逐行解读

  • monoDirectionalAirResistanceModel 是核心弹道模型。关键公式 t = (exp(k*s) - 1) / (k*v*cos(angle))二次阻力微分方程 \(dv_x/dt = -kv_x^2\) 的解析解——水平速度按双曲线 \(1/(1+kt)\) 衰减,而非指数衰减。
  • GRAVITY 在头文件中定义为 9.78(而非标准 9.8),这与实测标定有关。
  • pitchTrajectoryCompensation 使用不动点迭代法求解 pitch 补偿角:先猜一个角度,用弹道模型算出实际落点高度,再根据偏差调整猜测。dz = 0.3 * (z - z_actual) 中的 0.3 是阻尼系数,防止迭代振荡。
  • iterativeFlyTimeCompensation 是飞行时间迭代——因为子弹需要时间才能飞到目标,而目标在这段时间内会移动,所以需要反复更新预测位置。

对应的头文件:solve_trajectory.hpp

#ifndef __SOLVETRAJECTORY_H__
#define __SOLVETRAJECTORY_H__
#ifndef PI
#define PI 3.1415926535f
#endif
#define GRAVITY 9.78  // 重力加速度(实测值)
typedef unsigned char uint8_t;

// 目标装甲板类型枚举
enum ARMOR_ID {
  ARMOR_OUTPOST = 0,   // 前哨站
  ARMOR_HERO = 1,      // 英雄
  ARMOR_ENGINEER = 2,  // 工程
  ARMOR_INFANTRY3 = 3, // 步兵3号
  ARMOR_INFANTRY4 = 4, // 步兵4号
  ARMOR_INFANTRY5 = 5, // 步兵5号
  ARMOR_GUARD = 6,     // 哨兵
  ARMOR_BASE = 7       // 基地
};

// 装甲板数量枚举
enum ARMOR_NUM {
  ARMOR_NUM_BALANCE = 2,   // 平衡步兵(只有2块)
  ARMOR_NUM_OUTPOST = 3,   // 前哨站(3块)
  ARMOR_NUM_NORMAL = 4     // 普通机器人(4块)
};

enum BULLET_TYPE { BULLET_17 = 0, BULLET_42 = 1 };

// 弹道解算所需的所有参数
struct SolveTrajectoryParams
{
  float k = 0.092;  // 弹道系数(空气阻力系数/弹丸质量)

  // 自身参数
  enum BULLET_TYPE bullet_type = BULLET_17;
  float current_v = 20;       // 当前弹速 (m/s)
  float current_pitch;        // 当前云台 pitch
  float current_yaw;          // 当前云台 yaw

  // 目标参数(来自跟踪器)
  float xw, yw, zw;           // 目标在世界坐标系下的位置
  float vxw, vyw, vzw;        // 目标在世界坐标系下的速度
  float tar_yaw;               // 目标朝向 yaw 角
  float v_yaw;                 // 目标 yaw 角速度
  float r1;                    // 目标中心到前后装甲板的距离
  float r2;                    // 目标中心到左右装甲板的距离
  float dz;                    // 另一对装甲板的高度差
  int bias_time = 0;           // 发弹延时补偿 (ms)
  float s_bias = 0.1115;       // 枪口前推的距离 (m)
  float z_bias = 0.185;        // yaw 轴电机到枪口水平面的垂直距离 (m)
  enum ARMOR_ID armor_id;
  enum ARMOR_NUM armor_num;

  uint8_t time = 0;
};

// 存储每个装甲板在世界坐标系下的信息
struct tar_pos
{
  float x, y, z;   // 装甲板位置
  float yaw;        // 装甲板朝向
};

extern struct SolveTrajectoryParams st;

// 单方向空气阻力模型
float monoDirectionalAirResistanceModel(float s, float v, float angle);
// pitch 弹道补偿
float pitchTrajectoryCompensation(float s, float y, float v);
// 自动解算弹道(完整流程)
uint8_t autoSolveTrajectory(
  float * pitch, float * yaw, float * aim_x, float * aim_y, float * aim_z);

#endif

逐行解读

  • float k = 0.092:这是弹道系数,定义为 \(k = \frac{\rho C_d A}{2m}\)。对于 17mm 标准弹丸,\(\rho = 1.225\)\(C_d \approx 0.47\)\(A \approx 2.27 \times 10^{-4}\) m²,\(m \approx 0.003\) kg,计算得 \(k \approx 0.044\)。实际代码中的 0.092 是通过实验标定的,与理论值有差异是正常的——真实弹丸不是完美球体。
  • s_bias = 0.1115:枪口相对于 yaw 轴电机的前推距离,用于坐标变换。
  • z_bias = 0.185:yaw 轴电机相对于枪口的垂直高度差,用于坐标变换。
  • ARMOR_NUM_BALANCE = 2:平衡步兵(如 RMUL 工程机器人)只有前后两块装甲板,与普通步兵的四块不同,选板逻辑需要区分。
  • tar_pos 结构体存储了每块装甲板在世界坐标系中的位置和朝向,供后续选择最优装甲板使用。

两种模型的对比

特性 抛物线模型 (sp_vision_25) 空气阻力模型 (RM_Vision_2027)
计算量 极低(1次公式计算) 中等(最多20次迭代)
近距离精度(<5m) 够用 很高
远距离精度(>5m) 偏差明显 精确
代码复杂度 约 30 行 约 120 行
是否需要调参 不需要 需要设置弹道系数 \(k\)
飞行时间计算 解析公式 数值迭代

实际建议:如果你是新手入门,先用抛物线模型跑通整个流程,再升级到空气阻力模型。

关于弹道系数 k 的标定值与理论值

头文件中 k = 0.092,但根据物理参数计算的理论值约为 0.044:

\[ k_{\text{理论}} = \frac{\rho C_d A}{2m} = \frac{1.225 \times 0.47 \times 2.27 \times 10^{-4}}{2 \times 0.003} \approx 0.044 \]

实际代码中的 0.092 约为理论值的 2 倍。这个差异来源于:

  1. 弹丸不是完美球体:17mm 弹丸在高速旋转下会有变形,实际 \(C_d\) 比球形的 0.47 高。
  2. 简化模型的补偿:代码只考虑水平方向阻力(垂直方向用匀加速),这种近似会低估弹道下落。增大 \(k\) 可以在一定程度上补偿这个误差。
  3. 环境因素:不同温度、湿度、海拔下的空气密度不同,标定值包含了特定场地的修正。

实践建议:不要直接使用理论值。在实际场地中,通过测量不同距离的弹道落点来反标定 \(k\) 值,才是可靠的做法。

代码中的潜在问题

1. completeAirResistanceModel 是空函数

float completeAirResistanceModel(float s, float v, float angle) { return 0; }

这个函数在头文件中声明了,在 .cpp 中只有空壳。它的意图应该是同时考虑水平和垂直方向的空气阻力(完整的二维弹道模型),但从未被实现。当前所有弹道计算都只使用 monoDirectionalAirResistanceModel——垂直方向完全没有阻力衰减,这是一个已知的简化。

2. 全局变量 t 的风险

float t = 0.6f;  // 全局变量

t 是全局变量,在 monoDirectionalAirResistanceModel 中被直接赋值,在 autoSolveTrajectory 中被读取用于计算总延迟。这带来两个问题:

  • 线程安全:如果多线程调用弹道解算(例如同时处理多个目标),t 的值会被覆盖。
  • 隐式耦合autoSolveTrajectory 通过全局变量 t 获取飞行时间,而不是通过函数返回值。这使得数据流难以追踪,也使得单元测试困难。

3. tar_yaw 累加 bug

autoSolveTrajectory 中:

st.tar_yaw += st.v_yaw * timeDelay;

这行代码直接修改了 st.tar_yaw。由于 st 是全局结构体,如果 autoSolveTrajectory 被周期性调用(每帧一次),tar_yaw 会在每一帧被累加,而不是相对于原始值做增量。正确的做法是使用临时变量:

float predicted_yaw = st.tar_yaw + st.v_yaw * timeDelay;  // 不修改原值

iterativeFlyTimeCompensation 中有同样的问题(st.tar_yaw += st.v_yaw * total_delay 在循环内被多次累加)。


10.4 autoSolveTrajectory:完整弹道解算流程

RM_Vision_2027 的 autoSolveTrajectory 是弹道解算的总入口函数,它把装甲板选择、弹道计算和射击指令生成全部整合在一起。

/*
@brief 根据最优决策得出被击打装甲板,自动解算弹道
       使用 coming/leaving angle 逻辑用于陀螺目标
@param pitch:rad  传出 pitch 补偿角
@param yaw:rad    传出 yaw 指令
@param aim_x, aim_y, aim_z: 传出瞄准点坐标
@return 1:成功  0:失败
*/
uint8_t autoSolveTrajectory(
  float * pitch, float * yaw, float * aim_x, float * aim_y, float * aim_z)
{
  // ── 第一步:线性预测 ──
  // 计算总延迟 = 发弹延时 + 上次飞行时间
  float timeDelay = st.bias_time / 1000.0 + t;
  // 预测目标在延迟后的新朝向
  st.tar_yaw += st.v_yaw * timeDelay;

  // ── 第二步:根据目标类型计算所有装甲板位置 ──
  int use_1 = 1;
  int i = 0;
  int idx = 0;  // 选中的装甲板索引

  if (st.armor_num == ARMOR_NUM_BALANCE) {
    // ===== 平衡步兵:只有 2 块装甲板(前、后)=====
    for (i = 0; i < 2; i++) {
      float tmp_yaw = st.tar_yaw + i * PI;
      float r = st.r1;
      tar_position[i].x = st.xw - r * cos(tmp_yaw);
      tar_position[i].y = st.yw - r * sin(tmp_yaw);
      tar_position[i].z = st.zw;
      tar_position[i].yaw = tmp_yaw;
    }

    // 选择 yaw 角差最小的装甲板
    float yaw_diff_min = fabsf(*yaw - tar_position[0].yaw);
    float temp_yaw_diff = fabsf(*yaw - tar_position[1].yaw);
    if (temp_yaw_diff < yaw_diff_min) {
      yaw_diff_min = temp_yaw_diff;
      idx = 1;
    }

  } else if (st.armor_num == ARMOR_NUM_OUTPOST) {
    // ===== 前哨站:3 块装甲板,间隔 120° =====
    for (i = 0; i < 3; i++) {
      float tmp_yaw = st.tar_yaw + i * 2.0 * PI / 3.0;
      float r = (st.r1 + st.r2) / 2;
      tar_position[i].x = st.xw - r * cos(tmp_yaw);
      tar_position[i].y = st.yw - r * sin(tmp_yaw);
      tar_position[i].z = st.zw;
      tar_position[i].yaw = tmp_yaw;
    }

    // 选择 yaw 角差最小的装甲板
    float yaw_diff_min = fabsf(*yaw - tar_position[0].yaw);
    idx = 0;
    for (i = 1; i < 3; i++) {
      float temp_yaw_diff = fabsf(*yaw - tar_position[i].yaw);
      if (temp_yaw_diff < yaw_diff_min) {
        yaw_diff_min = temp_yaw_diff;
        idx = i;
      }
    }

  } else {
    // ===== 普通 4 装甲板:间隔 90°,前后/左右半径不同 =====
    for (i = 0; i < 4; i++) {
      float tmp_yaw = st.tar_yaw + i * PI / 2.0;
      float r = use_1 ? st.r1 : st.r2;
      tar_position[i].x = st.xw - r * cos(tmp_yaw);
      tar_position[i].y = st.yw - r * sin(tmp_yaw);
      tar_position[i].z = use_1 ? st.zw : st.zw + st.dz;
      tar_position[i].yaw = tmp_yaw;
      use_1 = !use_1;  // 前后交替使用不同半径
    }

    // ── 小陀螺模式:coming/leaving angle 逻辑 ──
    float center_yaw = atan2f(st.yw, st.xw);

    constexpr float spin_threshold = 2.0f;        // rad/s
    constexpr float coming_angle = 55.0f * PI / 180.0f;   // 55°
    constexpr float leaving_angle = 20.0f * PI / 180.0f;  // 20°

    if (fabsf(st.v_yaw) > spin_threshold) {
      float ca = coming_angle;
      float la = leaving_angle;

      // 筛选处于 coming 阶段的装甲板
      std::vector<int> candidate_indices;
      for (i = 0; i < 4; i++) {
        float delta_angle = tar_position[i].yaw - center_yaw;
        // 归一化到 [-π, π]
        while (delta_angle > PI) delta_angle -= 2 * PI;
        while (delta_angle <= -PI) delta_angle += 2 * PI;

        if (fabsf(delta_angle) > ca) continue;  // 角度太大,跳过
        // 根据旋转方向判断 coming/leaving
        if (st.v_yaw > 0 && delta_angle < la) {
          candidate_indices.push_back(i);
        } else if (st.v_yaw < 0 && delta_angle > -la) {
          candidate_indices.push_back(i);
        }
      }

      if (!candidate_indices.empty()) {
        // 在候选装甲板中选择距离最近的
        float min_dist = INFINITY;
        for (int ci : candidate_indices) {
          float dist = tar_position[ci].x * tar_position[ci].x +
                       tar_position[ci].y * tar_position[ci].y;
          if (dist < min_dist) {
            min_dist = dist;
            idx = ci;
          }
        }
        // 计算瞄准点并解算弹道
        *aim_z = tar_position[idx].z + st.vzw * timeDelay;
        *aim_x = tar_position[idx].x + st.vxw * timeDelay;
        *aim_y = tar_position[idx].y + st.vyw * timeDelay;
        float temp_pitch = -pitchTrajectoryCompensation(
          sqrt((*aim_x) * (*aim_x) + (*aim_y) * (*aim_y)) - st.s_bias,
          *aim_z + st.z_bias, st.current_v);
        if (temp_pitch) *pitch = temp_pitch;
        if (*aim_x || *aim_y) *yaw = (float)(atan2(*aim_y, *aim_x));
        return 1;
      }
    }

    // ── 非小陀螺模式:加权距离+角度决策 ──
    float min = INFINITY;
    float yaw_to_shooter = atan2f(st.yw, st.xw);
    for (int i = 0; i < 4; i++) {
      // 只考虑面向枪口方向的装甲板(delta_angle < 18°)
      if (fabsf(tar_position[i].yaw - yaw_to_shooter) > 0.1 * PI) {
        continue;
      }

      const float dist =
        tar_position[i].x * tar_position[i].x + tar_position[i].y * tar_position[i].y;
      const float yaw_diff = fabsf(*yaw - tar_position[i].yaw);
      // 加权评分:距离权重 0.5 + 角度权重 0.5
      if (dist * 0.5 + yaw_diff * 0.5 < min) {
        min = dist * 0.5 + yaw_diff * 0.5;
        idx = i;
      }
    }
    if (min == INFINITY) {
      return 0;  // 没有合适的装甲板
    }
  }

  // ── 第三步:计算最终瞄准点和弹道 ──
  *aim_z = tar_position[idx].z + st.vzw * timeDelay;
  *aim_x = tar_position[idx].x + st.vxw * timeDelay;
  *aim_y = tar_position[idx].y + st.vyw * timeDelay;

  float temp_pitch = -pitchTrajectoryCompensation(
    sqrt((*aim_x) * (*aim_x) + (*aim_y) * (*aim_y)) - st.s_bias,
    *aim_z + st.z_bias, st.current_v);
  if (temp_pitch) *pitch = temp_pitch;
  if (*aim_x || *aim_y) *yaw = (float)(atan2(*aim_y, *aim_x));
  return 1;
}

逐行解读

  • 第一步timeDelay = st.bias_time / 1000.0 + t,总延迟包括发弹延时(电控从接收指令到实际发射的延时)和子弹飞行时间(上一轮计算的 t)。st.tar_yaw += st.v_yaw * timeDelay 是线性预测目标在延迟后的新朝向。
  • 装甲板位置计算:每块装甲板的位置由目标中心位置 (xw, yw, zw) 加上旋转半径 r 在对应 yaw 角的偏移得到。use_1 = !use_1 实现前后/左右装甲板的半径交替(普通步兵前后装甲板到中心距离 r1 不同于左右 r2)。
  • 小陀螺判断fabsf(st.v_yaw) > spin_threshold 判断目标是否在快速旋转。coming_angle = 55° 表示装甲板距离正对枪口最多 55° 时才算"coming 阶段",leaving_angle = 20° 表示角度小于 20° 时算"leaving 阶段"。
  • 加权评分决策:非小陀螺模式下,用 dist * 0.5 + yaw_diff * 0.5 对每块装甲板评分——既要近,又要正对枪口。
  • 坐标变换s_bias = 0.1115 是枪口前推距离,z_bias = 0.185 是枪口与 yaw 电机的高度差,这些偏移量用于将目标坐标从 yaw 轴电机中心转换到枪口位置。

10.5 瞄准点选择(Aimer)—— choose_aim_point 详解

Aimer 是瞄准系统的大脑。它接收跟踪器输出的目标信息,选择最优的装甲板作为瞄准点。

sp_vision_25 的 choose_aim_point

以下代码来自 sp_vision_25/tasks/auto_aim/aimer.cpp

AimPoint Aimer::choose_aim_point(const Target & target)
{
  Eigen::VectorXd ekf_x = target.ekf_x();
  std::vector<Eigen::Vector4d> armor_xyza_list = target.armor_xyza_list();
  auto armor_num = armor_xyza_list.size();

  // 如果装甲板未发生过跳变,则只有当前装甲板的位置已知
  if (!target.jumped) return {true, armor_xyza_list[0]};

  // 整车旋转中心的球坐标 yaw(从云台看目标中心的方向角)
  auto center_yaw = std::atan2(ekf_x[2], ekf_x[0]);

  // 计算每块装甲板相对枪口方向的 delta_angle
  // delta_angle = 0 表示该装甲板正对枪口
  std::vector<double> delta_angle_list;
  for (int i = 0; i < armor_num; i++) {
    auto delta_angle = tools::limit_rad(armor_xyza_list[i][3] - center_yaw);
    delta_angle_list.emplace_back(delta_angle);
  }

  // ===== 不考虑小陀螺(角速度 ≤ 2 rad/s,且非前哨站)=====
  if (std::abs(target.ekf_x()[8]) <= 2 && target.name != ArmorName::outpost) {
    // 筛选在可射击范围内的装甲板(|delta_angle| ≤ 60°)
    std::vector<int> id_list;
    for (int i = 0; i < armor_num; i++) {
      if (std::abs(delta_angle_list[i]) > 60 / 57.3) continue;
      id_list.push_back(i);
    }

    if (id_list.empty()) {
      tools::logger()->warn("Empty id list!");
      return {false, armor_xyza_list[0]};
    }

    // 锁定模式:防止在两个都呈 45° 的装甲板之间来回切换
    if (id_list.size() > 1) {
      int id0 = id_list[0], id1 = id_list[1];

      // 未处于锁定模式时,选择 delta_angle 绝对值较小的,进入锁定
      if (lock_id_ != id0 && lock_id_ != id1)
        lock_id_ = (std::abs(delta_angle_list[id0]) < std::abs(delta_angle_list[id1]))
                     ? id0 : id1;

      return {true, armor_xyza_list[lock_id_]};
    }

    // 只有一个装甲板在可射击范围内时,退出锁定模式
    lock_id_ = -1;
    return {true, armor_xyza_list[id_list[0]]};
  }

  // ===== 小陀螺/前哨站模式:使用 coming/leaving angle =====
  double coming_angle, leaving_angle;
  if (target.name == ArmorName::outpost) {
    coming_angle = 70 / 57.3;   // 前哨站角度更大
    leaving_angle = 30 / 57.3;
  } else {
    coming_angle = comming_angle_;  // 从配置文件读取
    leaving_angle = leaving_angle_;
  }

  // 一侧的装甲板不断出现(coming),另一侧不断消失(leaving)
  // 优先打 coming 的那侧
  for (int i = 0; i < armor_num; i++) {
    if (std::abs(delta_angle_list[i]) > coming_angle) continue;
    if (ekf_x[7] > 0 && delta_angle_list[i] < leaving_angle)
      return {true, armor_xyza_list[i]};
    if (ekf_x[7] < 0 && delta_angle_list[i] > -leaving_angle)
      return {true, armor_xyza_list[i]};
  }

  return {false, armor_xyza_list[0]};
}

逐行解读

  • !target.jumped:如果跟踪器从未见过装甲板跳变(说明目标不旋转或旋转很慢),此时只有一块装甲板位置已知,直接返回它即可。
  • center_yaw = atan2(ekf_x[2], ekf_x[0]):这是从云台(世界坐标系原点)到目标中心的方位角。
  • delta_angle:每块装甲板的 yaw 角减去 center_yaw 后归一化到 \([-\pi, \pi]\)delta_angle = 0 表示该装甲板正对枪口。
  • 锁定模式:当两块装甲板同时在可射击范围内(都在 ±60° 以内)时,算法会锁定其中一块,避免帧间跳变。lock_id_ 记住上次锁定的装甲板 ID,只有当只剩一块时才退出锁定。
  • coming/leaving 判断ekf_x[7] > 0 表示目标逆时针旋转。delta_angle_list[i] < leaving_angle 表示该装甲板正在从逆时针方向靠近枪口(coming 阶段)。leaving_angle 通常为 20°,对应装甲板"即将到达正前方"的状态。
  • 前哨站的 coming_angle = 70°leaving_angle = 30° 比普通步兵大,因为前哨站有三块装甲板,间隔 120°,需要更大的角度容差。

RM_Vision_2027 的 choose_aim_point

RM_Vision_2027 的版本在 tools/aimer.cpp 中,逻辑与 sp_vision_25 基本相同,但使用 Eigen 向量和更现代的 C++ 风格:

AimPoint Aimer::choose_aim_point(
  const Eigen::VectorXd & ekf_x,
  const std::vector<Eigen::Vector4d> & armor_xyza_list,
  bool jumped, int armor_num)
{
  // 如果装甲板未跳变,只有当前装甲板已知
  if (!jumped) return {true, armor_xyza_list[0]};

  // 整车旋转中心在世界坐标系下的方位角
  double center_yaw = std::atan2(ekf_x[2], ekf_x[0]);

  // 计算每块装甲板的 delta_angle
  std::vector<double> delta_angle_list;
  for (int i = 0; i < armor_num; i++) {
    double delta_angle = limit_rad(armor_xyza_list[i][3] - center_yaw);
    delta_angle_list.push_back(delta_angle);
  }

  // 非旋转目标:选择在可射击范围内的装甲板 + 锁定模式
  if (std::abs(ekf_x[7]) <= 2 && armor_num != 3) {
    std::vector<int> id_list;
    for (int i = 0; i < armor_num; i++) {
      if (std::abs(delta_angle_list[i]) > 60.0 / 180.0 * M_PI) continue;
      id_list.push_back(i);
    }

    if (id_list.empty()) {
      return {false, armor_xyza_list[0]};
    }

    // 锁定模式
    if (id_list.size() > 1) {
      int id0 = id_list[0], id1 = id_list[1];
      if (lock_id_ != id0 && lock_id_ != id1)
        lock_id_ = (std::abs(delta_angle_list[id0]) < std::abs(delta_angle_list[id1]))
                     ? id0 : id1;
      return {true, armor_xyza_list[static_cast<int>(lock_id_)]};
    }

    lock_id_ = -1;
    return {true, armor_xyza_list[id_list[0]]};
  }

  // 旋转目标:coming/leaving angle 逻辑
  double coming_angle = params_.coming_angle;
  double leaving_angle = params_.leaving_angle;

  if (armor_num == 3) {  // 前哨站
    coming_angle = 70.0 / 180.0 * M_PI;
    leaving_angle = 30.0 / 180.0 * M_PI;
  }

  for (int i = 0; i < armor_num; i++) {
    if (std::abs(delta_angle_list[i]) > coming_angle) continue;
    if (ekf_x[7] > 0 && delta_angle_list[i] < leaving_angle)
      return {true, armor_xyza_list[i]};
    if (ekf_x[7] < 0 && delta_angle_list[i] > -leaving_angle)
      return {true, armor_xyza_list[i]};
  }

  return {false, armor_xyza_list[0]};
}

逐行解读

  • 逻辑与 sp_vision_25 版本完全一致,区别在于参数从配置文件读取(params_.coming_angle)而非硬编码。
  • armor_xyza_list[i][3] 是装甲板的 yaw 角度(存放在 Vector4d 的第 4 个分量中)。
  • limit_rad 函数将角度归一化到 \([-\pi, \pi]\) 范围,与 sp_vision_25 中的 tools::limit_rad 功能相同。
  • 返回的 AimPoint.xyza 包含装甲板的三维坐标 (x, y, z) 和角度 angle,供后续弹道计算使用。

10.6 飞行时间迭代

问题:子弹飞行期间目标在移动

弹道解算假设目标是静止的。但实际上,子弹从枪口飞到目标需要时间(通常 \(0.1 \sim 0.3\) 秒),在这段时间内目标可能移动了。

sp_vision_25 的飞行时间迭代

以下代码来自 sp_vision_25/tasks/auto_aim/aimer.cppaim() 函数:

// 选择初始瞄准点
auto aim_point0 = choose_aim_point(target);
debug_aim_point = aim_point0;
if (!aim_point0.valid) {
  return {false, false, 0, 0};
}

// 计算初始弹道(用于得到第一次飞行时间估计)
Eigen::Vector3d xyz0 = aim_point0.xyza.head(3);
auto d0 = std::sqrt(xyz0[0] * xyz0[0] + xyz0[1] * xyz0[1]);
tools::Trajectory trajectory0(bullet_speed, d0, xyz0[2]);
if (trajectory0.unsolvable) {
  debug_aim_point.valid = false;
  return {false, false, 0, 0};
}

// 迭代求解飞行时间(最多 10 次)
bool converged = false;
double prev_fly_time = trajectory0.fly_time;
tools::Trajectory current_traj = trajectory0;
std::vector<Target> iteration_target(10, target);

for (int iter = 0; iter < 10; ++iter) {
  // 预测目标在 future + prev_fly_time 时刻的位置
  auto predict_time =
    future + std::chrono::microseconds(static_cast<int>(prev_fly_time * 1e6));
  iteration_target[iter].predict(predict_time);

  // 重新选择瞄准点(目标位置已变)
  auto aim_point = choose_aim_point(iteration_target[iter]);
  debug_aim_point = aim_point;
  if (!aim_point.valid) {
    return {false, false, 0, 0};
  }

  // 重新计算弹道
  Eigen::Vector3d xyz = aim_point.xyza.head(3);
  double d = std::sqrt(xyz.x() * xyz.x() + xyz.y() * xyz.y());
  current_traj = tools::Trajectory(bullet_speed, d, xyz.z());

  if (current_traj.unsolvable) {
    debug_aim_point.valid = false;
    return {false, false, 0, 0};
  }

  // 收敛条件:相邻两次飞行时间差 < 1ms
  if (std::abs(current_traj.fly_time - prev_fly_time) < 0.001) {
    converged = true;
    break;
  }
  prev_fly_time = current_traj.fly_time;
}

// 计算最终角度
Eigen::Vector3d final_xyz = debug_aim_point.xyza.head(3);
double yaw = std::atan2(final_xyz.y(), final_xyz.x()) + yaw_offset_;
double pitch = -(current_traj.pitch + pitch_offset_);
return {true, false, yaw, pitch};

逐行解读

  • trajectory0 是基于当前目标位置的初始弹道估计,它的 fly_time 是第一次飞行时间猜测。
  • iteration_target[iter].predict(predict_time):将目标的 EKF 状态预测到 predict_time 时刻。这一步是关键——随着飞行时间更新,目标的新位置也会改变。
  • 每次迭代都重新调用 choose_aim_point,因为目标位置变化后,最优装甲板可能也变了(比如旋转目标在子弹飞行期间转到了另一块装甲板)。
  • std::abs(current_traj.fly_time - prev_fly_time) < 0.001:当两次迭代的飞行时间差小于 1ms 时认为收敛。通常 3~5 次就能收敛。
  • pitch = -(current_traj.pitch + pitch_offset_):注意负号——在世界坐标系下 pitch 向上为正,但云台控制通常 pitch 向上为负。

RM_Vision_2027 的飞行时间迭代

RM_Vision_2027 的迭代逻辑内嵌在 aim() 函数中,与 sp_vision_25 思路相同但使用 Eigen 线性预测而非 Target::predict:

// 迭代求解飞行时间(最多 10 次)
double prev_fly_time = trajectory0.fly_time;
Trajectory current_traj = trajectory0;

for (int iter = 0; iter < 10; ++iter) {
  // 预测目标在 future + fly_time 时刻的状态
  double iter_dt = total_dt + prev_fly_time;
  Eigen::VectorXd ekf_x_iter = ekf_x;
  ekf_x_iter[0] += ekf_x[1] * iter_dt;   // xc += vxc * dt
  ekf_x_iter[2] += ekf_x[3] * iter_dt;   // yc += vyc * dt
  ekf_x_iter[4] += ekf_x[5] * iter_dt;   // zc += vzc * dt
  ekf_x_iter[6] = limit_rad(ekf_x[6] + ekf_x[7] * iter_dt); // yaw

  // 根据预测状态重新计算装甲板位置
  std::vector<Eigen::Vector4d> iter_armor_xyza;
  for (int i = 0; i < armor_num; i++) {
    double r = (armor_num == 4 && (i == 1 || i == 3))
                 ? ekf_x_iter[8] + ekf_x_iter[9]
                 : ekf_x_iter[8];
    double angle = limit_rad(ekf_x_iter[6] + i * 2 * M_PI / armor_num);
    double ax = ekf_x_iter[0] - r * std::cos(angle);
    double ay = ekf_x_iter[2] - r * std::sin(angle);
    double az = (armor_num == 4 && (i == 1 || i == 3))
                  ? ekf_x_iter[4] + ekf_x_iter[10]
                  : ekf_x_iter[4];
    iter_armor_xyza.push_back({ax, ay, az, angle});
  }

  auto aim_point = choose_aim_point(ekf_x_iter, iter_armor_xyza, jumped, armor_num);
  debug_aim_point = aim_point;
  if (!aim_point.valid) {
    return {false, false, 0, 0};
  }

  Eigen::Vector3d xyz = aim_point.xyza.head(3);
  double d = std::sqrt(xyz.x() * xyz.x() + xyz.y() * xyz.y());
  current_traj = Trajectory(bullet_speed, d, xyz.z());

  if (current_traj.unsolvable) {
    debug_aim_point.valid = false;
    return {false, false, 0, 0};
  }

  if (std::abs(current_traj.fly_time - prev_fly_time) < 0.001) {
    break;
  }
  prev_fly_time = current_traj.fly_time;
}

逐行解读

  • 与 sp_vision_25 不同,RM_Vision_2027 在每次迭代中手动计算所有装甲板的位置,而不是调用 Target::predict。这是因为 RM_Vision_2027 的架构中,Aimer 独立于 Target 对象。
  • ekf_x_iter[8]r1(前后装甲板到中心的距离),ekf_x_iter[9]r2 - r1 的偏移量。对于普通 4 装甲板的步兵,第 1、3 块(从 0 计数)使用 r1 + (r2 - r1) = r2 作为半径。
  • ekf_x_iter[10]dz——另一对装甲板相对于被跟踪装甲板的高度差。
  • 迭代收敛条件同样是飞行时间差 < 1ms。

10.7 射击决策(Shooter)

核心问题:什么时候开火?

有了 pitch 补偿角和 yaw 指令后,还有一个关键问题:现在能开枪吗?

如果云台还没转到位就开火,子弹会打偏。射击决策模块的任务是判断当前的瞄准精度是否足够。

graph TD A["射击决策入口"] --> B{"目标是否被识别?"} B -- "否" --> X["不开火 ❌"] B -- "是" --> C{"yaw 指令连续性检查
|yaw_now - yaw_last| < 2·tolerance?"} C -- "跳变" --> X C -- "连续" --> D{"云台跟随精度检查
|gimbal_yaw - command_yaw| < tolerance?"} D -- "超过容差" --> X D -- "在容差内" --> E{"aim_point 有效?"} E -- "无效" --> X E -- "有效" --> F["允许开火 ✅"] style X fill:#ff6b6b,color:#fff style F fill:#51cf66,color:#fff

完整代码:sp_vision_25 Shooter

以下代码来自 sp_vision_25/tasks/auto_aim/shooter.cpp

#include "shooter.hpp"

#include <yaml-cpp/yaml.h>

#include "tools/logger.hpp"
#include "tools/math_tools.hpp"

namespace auto_aim
{
Shooter::Shooter(const std::string & config_path) : last_command_{false, false, 0, 0}
{
  auto yaml = YAML::LoadFile(config_path);
  first_tolerance_ = yaml["first_tolerance"].as<double>() / 57.3;    // 近距离容差(度→弧度)
  second_tolerance_ = yaml["second_tolerance"].as<double>() / 57.3;  // 远距离容差(度→弧度)
  judge_distance_ = yaml["judge_distance"].as<double>();              // 近/远距离分界
  auto_fire_ = yaml["auto_fire"].as<bool>();                          // 是否启用自动开火
}

bool Shooter::shoot(
  const io::Command & command, const auto_aim::Aimer & aimer,
  const std::list<auto_aim::Target> & targets, const Eigen::Vector3d & gimbal_pos)
{
  // 基本检查:必须有控制指令、有目标、且启用自动开火
  if (!command.control || targets.empty() || !auto_fire_) return false;

  // 获取目标位置,计算距离
  auto target_x = targets.front().ekf_x()[0];
  auto target_y = targets.front().ekf_x()[2];
  // 根据距离选择容差:近→first_tolerance,远→second_tolerance
  auto tolerance = std::sqrt(
    tools::square(target_x) + tools::square(target_y)) > judge_distance_
                     ? second_tolerance_
                     : first_tolerance_;

  // 三重检查:
  // 1. yaw 指令连续性:两次指令差 < 2*tolerance,防止目标跳变
  // 2. 云台跟随精度:云台实际位置与指令差 < tolerance
  // 3. 瞄准点有效
  if (
    std::abs(last_command_.yaw - command.yaw) < tolerance * 2 &&
    std::abs(gimbal_pos[0] - last_command_.yaw) < tolerance &&
    aimer.debug_aim_point.valid) {
    last_command_ = command;
    return true;
  }

  last_command_ = command;
  return false;
}

}  // namespace auto_aim

逐行解读

  • first_tolerance_second_tolerance_:近距离(< judge_distance_)允许较大容差(例如 3°~5°),远距离需要更严格(约 2°)。这是因为远距离时角误差会被放大为更大的线误差。
  • yaw 指令连续性std::abs(last_command_.yaw - command.yaw) < tolerance * 2 检查两次射击指令之间的 yaw 差。如果目标突然跳变(比如从装甲板 A 切到装甲板 B),yaw 指令会突变,此时不应开火。
  • 云台跟随精度std::abs(gimbal_pos[0] - last_command_.yaw) < tolerance 检查云台实际位置是否跟上了指令。如果云台还在转动中就开火,子弹会打偏。
  • aimer.debug_aim_point.valid:只有当 Aimer 选到了有效的瞄准点时才允许开火。
  • last_command_ = command:每次调用都会更新上一次的指令,供下次连续性检查使用。

Shooter 对应的头文件

#ifndef AUTO_AIM__SHOOTER_HPP
#define AUTO_AIM__SHOOTER_HPP

#include <string>
#include "io/command.hpp"
#include "tasks/auto_aim/aimer.hpp"

namespace auto_aim
{
class Shooter
{
public:
  Shooter(const std::string & config_path);

  // 核心决策函数:是否允许开火
  bool shoot(
    const io::Command & command,      // 当前射击指令
    const auto_aim::Aimer & aimer,    // Aimer 实例(获取 debug_aim_point)
    const std::list<auto_aim::Target> & targets,  // 目标列表
    const Eigen::Vector3d & gimbal_pos);          // 云台当前位置

private:
  io::Command last_command_;   // 上一次的指令(用于连续性检查)
  double judge_distance_;      // 近/远距离分界线
  double first_tolerance_;     // 近距离容差
  double second_tolerance_;    // 远距离容差
  bool auto_fire_;             // 是否启用自动开火
};
}  // namespace auto_aim

#endif

逐行解读

  • last_command_ 保存上一次的射击指令,用于检测 yaw 指令是否发生跳变。
  • auto_fire_ 是一个全局开关——调试时可以关闭自动开火,只做跟随不开枪。
  • judge_distance_ 的典型值为 3~4 米,小于此距离用较大的 first_tolerance(3°~5°),大于此距离用较小的 second_tolerance(约 2°)。

RM_Vision_2027 的射击决策

RM_Vision_2027 没有独立的 Shooter 类。射击决策通过 AimCommand.shoot 字段传递,在 aim() 函数中始终返回 {true, false, yaw, pitch}——shoot = false 表示视觉端不直接发开火指令,开火决策交由电控端根据云台跟随精度判断。

// RM_Vision_2027 的 aim() 返回值
return {true, false, yaw, pitch};
//       control shoot  yaw  pitch
//       ↑       ↑
//       控制中   不射击(电控端自行判断)

射击决策方案对比

方案 开火决策位置 特点
rm_vision 传统方案 电控端 视觉只发目标坐标,电控自行判断
sp_vision_25 Shooter 视觉端 三重检查:指令连续性 + 云台精度 + 点有效性
RM_Vision_2027 电控端 视觉端只发跟随指令,不发开火指令

10.8 不同仓库的弹道方案对比

仓库 弹道模型 瞄准策略 开火决策位置 特点
rm_vision 电控端负责 视觉只输出目标坐标 电控端 视觉端最简单,职责分离
sp_vision_25 抛物线(解析解) 视觉端 Aimer + 锁定模式 视觉端 Shooter 类 自包含,视觉端全权负责
RM_Vision_2027 空气阻力(迭代求解) 视觉端 Aimer + 锁定模式 电控端 最精确,弹道模型最完善

架构差异图

graph LR subgraph "rm_vision 方案" direction TB V1["视觉端"] -- "目标 3D 坐标" --> E1["电控端"] E1 --> E1A["电控端做弹道解算"] E1A --> E1B["电控端做射击决策"] end subgraph "sp_vision_25 方案" direction TB V2["视觉端"] --> V2A["Aimer: 瞄准点选择
+ 抛物线弹道解算"] V2A --> V2B["Shooter: 三重射击决策"] V2B -- "yaw + pitch + 开火指令" --> E2["电控端执行"] end subgraph "RM_Vision_2027 方案" direction TB V3["视觉端"] --> V3A["Aimer: 瞄准点选择
+ 空气阻力弹道解算"] V3A --> V3B["仅发跟随指令
shoot = false"] V3B -- "yaw + pitch" --> E3["电控端判断开火"] end style E1 fill:#ffd43b,color:#333 style V2B fill:#4a9eff,color:#fff style E3 fill:#ffd43b,color:#333

10.9 完整数据流

把本章所有模块串起来,看看从"检测到目标"到"开火"的完整数据流:

graph TD A["目标跟踪器输出
位置(x,y,z) + 速度 + 装甲板列表"] --> B["Aimer: 瞄准点选择"] B --> B1{"角速度判断"} B1 -- "ω < 2 rad/s" --> B2["普通模式
锁定最近装甲板"] B1 -- "ω ≥ 2 rad/s" --> B3["小陀螺模式
coming/leaving 判断"] B2 --> C["选定目标装甲板"] B3 --> C C --> D["飞行时间迭代
最多 10 次,收敛阈值 1ms"] D --> E["弹道解算
计算 pitch 补偿角"] E --> E1{"模型选择"} E1 -- "简化" --> E2["抛物线解析解
(sp_vision_25)"] E1 -- "精确" --> E3["空气阻力迭代
最多 20 次
(RM_Vision_2027)"] E2 --> F["得到 yaw + pitch 指令"] E3 --> F F --> G["射击决策"] G --> G1{"sp_vision_25: 三重检查
RM_Vision_2027: 电控端判断"} G1 -- "通过" --> H["发送开火指令
yaw + pitch + fire ✅"] G1 -- "不通过" --> I["仅发送跟随指令
yaw + pitch,不开火 ❌"] style H fill:#51cf66,color:#fff style I fill:#ff6b6b,color:#fff

10.10 关键参数速查表

参数 符号 典型值 说明
子弹初速度 \(v_0\) 25~30 m/s 需实测,不同枪管不同
重力加速度 \(g\) 9.7833 m/s² 实测值,非标准 9.8
弹道系数 \(k\) 0.092 RM_Vision_2027,需实验标定
弹丸直径 \(d\) 17mm RM 标准弹丸
弹丸质量 \(m\) 约 3g 17mm 标准弹丸
pitch 迭代阻尼系数 - 0.3 RM_Vision_2027,防振荡
pitch 迭代收敛阈值 \(\epsilon\) \(10^{-5}\) m 高度偏差
pitch 迭代最大次数 - 20 pitch 不动点迭代
飞行时间迭代最大次数 - 10 目标预测迭代
飞行时间收敛阈值 - 0.001 s 1ms
小陀螺角速度阈值 \(\omega_{\text{thresh}}\) 2 rad/s 区分普通/反陀螺
coming_angle(步兵) - \(55°\) 装甲板靠近阶段
leaving_angle(步兵) - \(20°\) 装甲板远离阶段
coming_angle(前哨站) - \(70°\) 前哨站角度更大
leaving_angle(前哨站) - \(30°\) 前哨站角度更大
近距离射击容差 - \(3° \sim 5°\) sp_vision_25 Shooter
远距离射击容差 - \(\approx 2°\) sp_vision_25 Shooter
高速延迟时间 - 0.15 s ω > 2 rad/s 时
低速延迟时间 - 0.1 s ω ≤ 2 rad/s 时

10.11 调试建议

弹道补偿角的调试

  1. 固定靶测试:把装甲板固定在已知距离(3m、5m、8m),记录实际弹道偏差
  2. 逐步调参:先调准一个距离,再验证其他距离
  3. 记录日志:打印 pitch 补偿角、目标距离、飞行时间,分析是否合理

射击决策的调试

  1. 录屏回放:记录自瞄视频和开火时机,分析是否"该开时不开,不该开时乱开"
  2. 参数敏感度:把容差从大到小调,观察命中率和误射率的变化
  3. 分类统计:分别统计近距离和远距离的命中率

常见问题

现象 可能原因 解决方案
子弹总是打偏下方 pitch 补偿不足 检查初速度是否准确,增大补偿
子弹总是打偏上方 pitch 补偿过大 减小初速度参数或补偿系数
打旋转目标命中率低 coming/leaving 判断不准 调整角度阈值,检查角速度估计
开火时子弹打偏 开火时机太早 收紧射击容差,增加连续性检查
不开火 容差太紧或连续性检查太严 适当放宽容差
弹道系数不准 \(k\) 值标定偏差 在不同距离实测弹道,反算 \(k\)

本章小结

graph LR A["弹道解算"] --> B["瞄准点选择"] B --> C["飞行时间迭代"] C --> D["射击决策"] D --> E["高命中率"] A -.- A1["抛物线 / 空气阻力"] B -.- B1["普通模式 / 反陀螺"] C -.- C1["最多 10 次迭代"] D -.- D1["距离相关容差"] style E fill:#51cf66,color:#fff

本章我们完成了自瞄系统的最后环节:

  1. 弹道解算:把目标坐标转化为枪口的 pitch 补偿角——sp_vision_25 使用抛物线解析解(约 30 行代码),RM_Vision_2027 使用空气阻力迭代模型(约 120 行代码)
  2. 瞄准点选择(choose_aim_point):在多块装甲板中选出最优目标,关键策略是 coming/leaving angle 反陀螺 + 锁定模式防抖
  3. 飞行时间迭代:最多 10 次迭代,收敛阈值 1ms,补偿子弹飞行期间目标的移动
  4. 射击决策(Shooter):sp_vision_25 实现了完整的三重检查(指令连续性 + 云台精度 + 瞄准点有效性),RM_Vision_2027 则将开火决策交由电控端

至此,从图像输入到开火指令的完整自瞄流水线就全部讲完了。下一章我们将进入标定系统,学习如何让相机和 IMU 的数据精确对齐。