第 10 章:弹道解算与瞄准¶
从"看到"到"打中"的最后一步¶
在前面的章节中,我们已经完成了:
- 检测:从图像中找到装甲板(第 7 章)
- 跟踪:用 EKF 追踪目标运动状态(第 8 章)
- 位姿解算:用 PnP 得到目标在三维空间中的位置(第 9 章)
现在我们手里有了目标的三维坐标 \((x, y, z)\)。下一步很简单——打中它。
但事情没那么简单。子弹不是激光,从枪口飞到目标需要时间,期间会受重力和空气阻力影响。如果直接把枪口对准目标,子弹一定会打偏。
本章要解决的问题:
(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),离开枪口后会受到:
- 重力:子弹始终受到向下的重力加速度 \(g \approx 9.8 \, \text{m/s}^2\)
- 空气阻力:子弹飞行时受到与速度方向相反的阻力
这导致子弹的轨迹是一条抛物线(或更精确地说是一条受阻力影响的曲线),而不是直线。
弹道偏移有多严重?¶
我们来算一下。假设子弹初速度 \(v_0 = 28 \, \text{m/s}\),目标距离 \(d = 5 \, \text{m}\):
子弹飞行时间(忽略阻力):
重力导致的下落距离:
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 补偿角),才能让子弹命中目标。
10.2 抛物线弹道模型(sp_vision_25 实现)¶
模型假设¶
最简单的弹道模型做了一个大胆假设:忽略空气阻力。
这意味着子弹只受重力作用,轨迹是一条标准抛物线。同济大学的 sp_vision_25 仓库就采用这种模型。
物理公式¶
在忽略空气阻力的条件下,子弹的运动可以分解为两个方向:
水平方向(匀速运动):
垂直方向(匀加速运动):
其中:
| 符号 | 含义 | 典型值 |
|---|---|---|
| \(v_0\) | 子弹初速度 | 25~30 m/s |
| \(\theta\) | 发射角(相对水平面的仰角) | 待求 |
| \(t\) | 飞行时间 | 待求 |
| \(g\) | 重力加速度 | 9.7833 m/s² |
求解思路¶
已知目标距离 \(d\) 和高度差 \(h\),求发射角 \(\theta\)。
从水平方程解出飞行时间:
代入垂直方程:
化简:
利用 \(\frac{1}{\cos^2(\theta)} = 1 + \tan^2(\theta)\),令 \(u = \tan(\theta)\):
整理成关于 \(u\) 的二次方程:
用求根公式解出 \(\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 补偿角。
空气阻力公式¶
空气阻力的大小为:
| 符号 | 含义 | 典型值 |
|---|---|---|
| \(\rho\) | 空气密度 | 1.225 kg/m³ |
| \(C_d\) | 阻力系数(与弹丸形状有关) | 0.47(球形弹丸) |
| \(A\) | 弹丸截面积 | \(\pi r^2\),17mm 弹丸约 \(2.27 \times 10^{-4}\) m² |
| \(v\) | 弹丸当前速度 | 随时间衰减 |
RM_Vision_2027 的方案中,弹道系数被简化为一个参数 \(k = 0.092\),定义为:
其中 \(m\) 是弹丸质量。这个参数可以通过实验标定。
空气阻力公式的数学推导¶
代码中实际使用的阻力模型是二次阻力(与速度平方成正比),而非注释中暗示的线性阻力。以下是从物理方程到代码公式的完整推导。
第一步:建立微分方程
水平方向的运动方程(只考虑水平阻力,忽略垂直方向对水平的耦合):
其中 \(v_x(0) = v_0 \cos\theta\)。这是一个可分离变量的常微分方程。
第二步:分离变量并积分
第三步:解出速度
注意:这是双曲线衰减(\(1/(1+at)\)),而非注释中写的指数衰减(\(e^{-kt}\))。两者在小 \(kt\) 时接近,但在大距离下差异明显。
第四步:积分得到距离
令 \(u = 1 + k v_0 \cos\theta \cdot \tau\),则 \(du = k v_0 \cos\theta \, d\tau\):
第五步:反解飞行时间
这正是代码中的公式:
垂直方向(不受水平阻力影响,仍用匀加速运动):
即代码中的:
注意代码注释的错误:原代码注释写的是"水平速度衰减: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:
实际代码中的 0.092 约为理论值的 2 倍。这个差异来源于:
- 弹丸不是完美球体:17mm 弹丸在高速旋转下会有变形,实际 \(C_d\) 比球形的 0.47 高。
- 简化模型的补偿:代码只考虑水平方向阻力(垂直方向用匀加速),这种近似会低估弹道下落。增大 \(k\) 可以在一定程度上补偿这个误差。
- 环境因素:不同温度、湿度、海拔下的空气密度不同,标定值包含了特定场地的修正。
实践建议:不要直接使用理论值。在实际场地中,通过测量不同距离的弹道落点来反标定 \(k\) 值,才是可靠的做法。
代码中的潜在问题¶
1. completeAirResistanceModel 是空函数
这个函数在头文件中声明了,在 .cpp 中只有空壳。它的意图应该是同时考虑水平和垂直方向的空气阻力(完整的二维弹道模型),但从未被实现。当前所有弹道计算都只使用 monoDirectionalAirResistanceModel——垂直方向完全没有阻力衰减,这是一个已知的简化。
2. 全局变量 t 的风险
t 是全局变量,在 monoDirectionalAirResistanceModel 中被直接赋值,在 autoSolveTrajectory 中被读取用于计算总延迟。这带来两个问题:
- 线程安全:如果多线程调用弹道解算(例如同时处理多个目标),
t的值会被覆盖。 - 隐式耦合:
autoSolveTrajectory通过全局变量t获取飞行时间,而不是通过函数返回值。这使得数据流难以追踪,也使得单元测试困难。
3. tar_yaw 累加 bug
在 autoSolveTrajectory 中:
这行代码直接修改了 st.tar_yaw。由于 st 是全局结构体,如果 autoSolveTrajectory 被周期性调用(每帧一次),tar_yaw 会在每一帧被累加,而不是相对于原始值做增量。正确的做法是使用临时变量:
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.cpp 的 aim() 函数:
// 选择初始瞄准点
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 指令后,还有一个关键问题:现在能开枪吗?
如果云台还没转到位就开火,子弹会打偏。射击决策模块的任务是判断当前的瞄准精度是否足够。
|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 + 锁定模式 | 电控端 | 最精确,弹道模型最完善 |
架构差异图¶
+ 抛物线弹道解算"] 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 完整数据流¶
把本章所有模块串起来,看看从"检测到目标"到"开火"的完整数据流:
位置(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 调试建议¶
弹道补偿角的调试¶
- 固定靶测试:把装甲板固定在已知距离(3m、5m、8m),记录实际弹道偏差
- 逐步调参:先调准一个距离,再验证其他距离
- 记录日志:打印 pitch 补偿角、目标距离、飞行时间,分析是否合理
射击决策的调试¶
- 录屏回放:记录自瞄视频和开火时机,分析是否"该开时不开,不该开时乱开"
- 参数敏感度:把容差从大到小调,观察命中率和误射率的变化
- 分类统计:分别统计近距离和远距离的命中率
常见问题¶
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 子弹总是打偏下方 | pitch 补偿不足 | 检查初速度是否准确,增大补偿 |
| 子弹总是打偏上方 | pitch 补偿过大 | 减小初速度参数或补偿系数 |
| 打旋转目标命中率低 | coming/leaving 判断不准 | 调整角度阈值,检查角速度估计 |
| 开火时子弹打偏 | 开火时机太早 | 收紧射击容差,增加连续性检查 |
| 不开火 | 容差太紧或连续性检查太严 | 适当放宽容差 |
| 弹道系数不准 | \(k\) 值标定偏差 | 在不同距离实测弹道,反算 \(k\) |
本章小结¶
本章我们完成了自瞄系统的最后环节:
- 弹道解算:把目标坐标转化为枪口的 pitch 补偿角——sp_vision_25 使用抛物线解析解(约 30 行代码),RM_Vision_2027 使用空气阻力迭代模型(约 120 行代码)
- 瞄准点选择(choose_aim_point):在多块装甲板中选出最优目标,关键策略是 coming/leaving angle 反陀螺 + 锁定模式防抖
- 飞行时间迭代:最多 10 次迭代,收敛阈值 1ms,补偿子弹飞行期间目标的移动
- 射击决策(Shooter):sp_vision_25 实现了完整的三重检查(指令连续性 + 云台精度 + 瞄准点有效性),RM_Vision_2027 则将开火决策交由电控端
至此,从图像输入到开火指令的完整自瞄流水线就全部讲完了。下一章我们将进入标定系统,学习如何让相机和 IMU 的数据精确对齐。