端到端走读:一帧图像的旅程¶
本章以 一帧图像 为线索,从相机曝光到子弹出膛,完整追踪数据在 auto-aim 管线中的每一次变换。 每一步都给出关键代码、延迟估算和数据格式变化,帮助你建立 "数据流" 的全局直觉。
全景流程图¶
HikVision SDK
1440×1080 RGB8"] -->|"/image_raw
sensor_msgs/Image"| B["🖼️ 图像预处理
cvtColor → threshold"] B -->|"cv::Mat binary"| C["📏 灯条检测
findContours → minAreaRect"] C -->|"vector<LightBar>"| D["🔗 装甲板匹配
双循环配对"] D -->|"vector<Armor>"| E["🔢 数字识别
MLP推理"] E -->|"vector<Armor> + number"| F["📐 PnP位姿解算
SOLVEPNP_IPPE"] F -->|"/detector/armors
Armor.msg"| G["🌍 TF2坐标变换
camera → odom"] G -->|"geometry_msgs/PoseStamped"| H["📊 EKF预测
F×x"] H -->|"11维状态预测"| I["🎯 数据关联
球坐标匹配"] I -->|"匹配结果"| J["📊 EKF更新
卡尔曼增益"] J -->|"更新后状态"| K["🔄 状态机
LOST→TRACKING"] K -->|"/tracker/target
Target.msg"| L["🎯 弹道解算
抛物线模型"] L -->|"pitch/yaw补偿"| M["📡 串口发送
SendPacket"] M -->|"UART /dev/ttyACM0"| N["⚡ 电控接收
STM32"] style A fill:#4CAF50,color:#fff style F fill:#2196F3,color:#fff style J fill:#FF9800,color:#fff style N fill:#f44336,color:#fff
延迟时间线总览¶
| 步骤 | 模块 | 操作 | 延迟估算 |
|---|---|---|---|
| 1 | 相机采集 | SDK grab + 发布 Image | ~0 ms |
| 2 | 图像预处理 | cvtColor + threshold | ~1 ms |
| 3 | 灯条检测 | findContours + minAreaRect | ~2 ms |
| 4 | 装甲板匹配 | 双重循环配对 | ~1 ms |
| 5 | 数字识别 | 透视变换 + MLP推理 | ~3 ms |
| 6 | PnP解算 | solvePnP + 四元数转换 | ~0.1 ms |
| 7 | 消息发布 | publish Armor.msg | ~0 ms |
| 8 | TF2变换 | camera→odom | ~0.5 ms |
| 9 | EKF预测 | F矩阵×状态 | ~0.01 ms |
| 10 | 数据关联 | 球坐标匹配 | ~0.1 ms |
| 11 | EKF更新 | 卡尔曼增益 + 状态修正 | ~0.01 ms |
| 12 | 状态机 | 帧计数 + 状态转移 | ~0 ms |
| 13 | 消息发布 | publish Target.msg | ~0 ms |
| 14 | 弹道解算 | 抛物线迭代求解 | ~0.1 ms |
| 15 | 串口发送 | SendPacket + CRC16 | ~0.5 ms |
| 16 | 电控接收 | STM32解析 + PID控制 | ~1 ms |
| 合计端到端 | ~9.3 ms |
端到端延迟
从相机曝光到电控收到目标,典型延迟约 9~10ms(不含相机曝光时间和传输延迟)。 按 30fps 帧率(33ms 间隔),管线有充足的时间余量。瓶颈通常在 数字识别(MLP推理)。
数据格式变换表¶
| 步骤 | 输出话题/变量 | 数据类型 | 关键字段 |
|---|---|---|---|
| 1 | /image_raw |
sensor_msgs/Image |
width, height, encoding="rgb8", data[] |
| 2 | (内部) | cv::Mat |
8UC1 二值图 |
| 3 | (内部) | vector<LightBar> |
center, angle, length, width, color |
| 4 | (内部) | vector<Armor> |
left, right, type(SMALL/LARGE), distance |
| 5 | (内部) | vector<Armor> |
+ number, confidence |
| 6 | /detector/armors |
rm_interfaces/Armor.msg |
number, type, pose(Pose) |
| 8 | (内部) | geometry_msgs/PoseStamped |
odom坐标系下的位姿 |
| 9~11 | (内部) | Eigen::VectorXd(11) |
xc,vxc,yc,vyc,zc,vzc,yaw,vyaw,r,r_diff,dz |
| 13 | /tracker/target |
rm_interfaces/Target.msg |
position, velocity, yaw, v_yaw, radius_1, radius_2, dz |
| 14 | (内部) | double pitch, yaw |
弧度值 |
| 15 | (内部) | uint8_t[19] |
header+pitch+yaw+shoot+CRC16 |
| 16 | (内部) | uint16_t pitch, yaw |
电机CAN指令 |
Step 1:相机采集¶
延迟:~0 ms
HikVision 工业相机通过 MVS SDK 采集 1440×1080 分辨率的 RGB8 图像。相机节点在独立线程中循环调用 MV_CC_GetImageBuffer,获取帧后封装为 ROS2 的 sensor_msgs::msg::Image 发布到 /image_raw 话题。
// 相机驱动核心循环
while (rclcpp::ok()) {
MV_FRAME_OUT frame;
int ret = MV_CC_GetImageBuffer(handle, &frame, 1000);
if (ret != MV_OK) continue;
auto msg = std::make_unique<sensor_msgs::msg::Image>();
msg->header.stamp = this->now();
msg->header.frame_id = "camera_optical_frame";
msg->width = frame.stFrameInfo.nWidth; // 1440
msg->height = frame.stFrameInfo.nHeight; // 1080
msg->encoding = "rgb8";
msg->step = frame.stFrameInfo.nWidth * 3;
msg->data.assign(frame.pBufAddr,
frame.stFrameInfo.nFrameLen);
image_pub_->publish(std::move(msg));
MV_CC_FreeImageBuffer(handle, &frame);
}
解读
MV_CC_GetImageBuffer是阻塞调用,超时 1000ms 防止线程卡死。- 相机曝光时间由硬件控制,不在软件延迟中体现。
frame_id = "camera_optical_frame"是后续 TF 变换的起点,名称必须与 URDF 中一致。encoding = "rgb8"表示每像素 3 字节,R-G-B 顺序排列。- 1440×1080 分辨率的原始帧大小约 4.7 MB,通过 shared memory 传递几乎无延迟。
Step 2:图像预处理¶
延迟:~1 ms
detector_node 订阅 /image_raw,将 RGB 图像转为灰度图,再通过固定阈值二值化得到黑白二值图像。二值化是后续灯条检测的基础——装甲板灯条的亮度远高于背景,阈值分割可以快速提取高亮区域。
void DetectorNode::image_callback(const sensor_msgs::msg::Image::SharedPtr msg) {
// ROS Image → OpenCV Mat
cv::Mat rgb = cv_bridge::toCvCopy(msg, "rgb8")->image;
cv::Mat gray, binary;
// 转灰度
cv::cvtColor(rgb, gray, cv::COLOR_RGB2GRAY);
// 固定阈值二值化
cv::threshold(gray, binary, 80, 255, cv::THRESH_BINARY);
// 后续灯条检测使用 binary
detect_lights(binary, gray, lights);
}
解读
- 为什么用固定阈值 80 而不是 OTSU 自适应?比赛环境光照相对稳定,固定阈值更快且可预测。OTSU 每帧额外增加约 0.2ms。
- 灰度图
gray也传入后续步骤,用于灯条的颜色判断(需要保留 R/B 通道信息)。 - 二值化后,灯条区域为白色(255),背景为黑色(0),极大简化了轮廓检测。
Step 3:灯条检测¶
延迟:~2 ms
对二值图执行轮廓检测,提取所有亮区域的最小外接矩形。再通过长宽比、角度、面积等条件过滤出合格的灯条候选。
void DetectorNode::detect_lights(const cv::Mat & binary,
const cv::Mat & gray,
std::vector<LightBar> & lights) {
std::vector<std::vector<cv::Point>> contours;
cv::findContours(binary, contours, cv::RETR_EXTERNAL,
cv::CHAIN_APPROX_NONE);
for (const auto & contour : contours) {
if (contour.size() < 6) continue; // 面积太小跳过
cv::RotatedRect rect = cv::minAreaRect(contour);
// 灯条长宽比过滤:灯条是细长的,width/height 应在 0.1~0.4
float ratio = std::min(rect.size.width, rect.size.height)
/ std::max(rect.size.width, rect.size.height);
if (ratio < 0.1 || ratio > 0.4) continue;
// 角度过滤:灯条大致垂直,倾斜不超过 40°
float angle = rect.angle;
if (rect.size.width > rect.size.height) {
angle = angle + 90;
}
if (std::abs(angle) > 40.0) continue;
// 颜色判断:比较 R 通道和 B 通道
LightBar light;
light.center = rect.center;
light.length = std::max(rect.size.width, rect.size.height);
light.width = std::min(rect.size.width, rect.size.height);
light.angle = angle;
light.color = get_color(gray, rect);
lights.push_back(light);
}
}
颜色判断函数:
int DetectorNode::get_color(const cv::Mat & gray,
const cv::RotatedRect & rect) {
// 在灰度图上截取灯条区域
cv::Mat roi;
cv::getRectSubPix(gray, rect.size, rect.center, roi);
float mean_val = cv::mean(roi)[0];
// 更精确的做法:在原 RGB 图上比较 R/B 通道
// 这里简化:高亮区域偏红还是偏蓝
// 实际代码中会对 rgb 图做 R-B 通道比较
return (mean_val > 128) ? RED : BLUE;
}
解读
RETR_EXTERNAL只检测最外层轮廓,避免灯条内部高光产生的嵌套轮廓干扰。- 长宽比 0.1~0.4 对应典型的 LED 灯条比例。太宽可能是反光,太窄可能是噪点。
- 角度过滤剔除了水平放置的干扰物(如反光条)。
- 颜色判断是 分队关键——必须正确区分红蓝,否则后续匹配全部错乱。
- 此步骤耗时约 2ms,是管线中较重的计算之一(轮廓检测涉及大量像素遍历)。
Step 4:装甲板匹配¶
延迟:~1 ms
一个装甲板由左右两个同色灯条组成。双重循环遍历所有灯条配对,通过长度比、距离比和角度差过滤出合格的装甲板候选,并判断类型(小型/大型)。
void DetectorNode::match_armors(const std::vector<LightBar> & lights,
std::vector<Armor> & armors) {
for (size_t i = 0; i < lights.size(); i++) {
for (size_t j = i + 1; j < lights.size(); j++) {
// 必须同色
if (lights[i].color != lights[j].color) continue;
// 长度比过滤:两个灯条长度应接近
float len_ratio = std::min(lights[i].length, lights[j].length)
/ std::max(lights[i].length, lights[j].length);
if (len_ratio < 0.8) continue;
// 两灯条中心距 vs 平均长度 的比值判断类型
float dist = cv::norm(lights[i].center - lights[j].center);
float avg_len = (lights[i].length + lights[j].length) / 2.0f;
float dist_ratio = dist / avg_len;
Armor armor;
armor.left = lights[i];
armor.right = lights[j];
armor.type = (dist_ratio < 3.2) ? SMALL : LARGE;
// 角度差:两个灯条倾斜方向应大致一致
float angle_diff = std::abs(lights[i].angle - lights[j].angle);
if (angle_diff > 15.0) continue;
armors.push_back(armor);
}
}
}
解读
- 双重循环复杂度 O(n²),但 n 通常很小(10个灯条以内),实际开销极低。
- 长度比 > 0.8 防止错误匹配一个大灯条和一个小灯条。
- dist_ratio < 3.2 区分小型装甲板(2024赛季英雄/步兵)和大型装甲板(哨兵/基地)。
- 大型装甲板的 3D 模型尺寸不同,影响后续 PnP 解算精度。
- 角度差过滤防止将两个相邻但方向不同的灯条误匹配。
Step 5:数字识别¶
延迟:~3 ms(管线最大瓶颈)
对每个装甲板候选,截取两个灯条之间的 ROI 区域,通过透视变换校正为标准 20×28 图像,然后用训练好的 MLP 神经网络推理识别数字编号。
void DetectorNode::classify_armors(cv::Mat & src,
std::vector<Armor> & armors) {
for (auto & armor : armors) {
// 1. 构造 ROI 四个角点(灯条两端扩展)
std::vector<cv::Point2f> roi_pts = get_armor_roi(armor);
// 2. 透视变换校正
std::vector<cv::Point2f> dst_pts = {
{0, 0}, {20, 0}, {20, 28}, {0, 28}
};
cv::Mat M = cv::getPerspectiveTransform(roi_pts, dst_pts);
cv::Mat roi;
cv::warpPerspective(src, roi, M, cv::Size(20, 28));
// 3. 二值化
cv::cvtColor(roi, roi, cv::COLOR_RGB2GRAY);
cv::threshold(roi, roi, 0, 255,
cv::THRESH_BINARY | cv::THRESH_OTSU);
// 4. MLP 推理
cv::Mat input = roi.reshape(1, 1); // 展平为 1×560
input.convertTo(input, CV_32F, 1.0 / 255.0);
cv::Mat output;
mlp_->predict(input, output); // 输出 1×9
// 5. 取最大置信度
double max_val;
cv::Point max_loc;
cv::minMaxLoc(output, nullptr, &max_val, nullptr, &max_loc);
if (max_val > 0.8) {
armor.number = max_loc.x + 1; // 数字 1~9
armor.confidence = max_val;
} else {
armor.number = 0; // 未识别
}
}
}
MLP 网络结构:
// 加载预训练的 MLP 模型
cv::Ptr<cv::ml::ANN_MLP> mlp_ = cv::ml::ANN_MLP::load("mlp.xml");
// 网络结构: 560(输入) → 256(隐藏层) → 9(输出层)
// 560 = 20 × 28(ROI图像展平)
// 9 个输出对应数字 1~9
装甲板 ROI 提取:
std::vector<cv::Point2f> DetectorNode::get_armor_roi(const Armor & armor) {
// 从两个灯条的四个端点提取装甲板区域
auto tl = armor.left.center + cv::Point2f(-armor.left.width * 0.5, 0);
auto bl = armor.left.center + cv::Point2f( armor.left.width * 0.5, 0);
auto tr = armor.right.center + cv::Point2f(-armor.right.width * 0.5, 0);
auto br = armor.right.center + cv::Point2f( armor.right.width * 0.5, 0);
return {tl, tr, br, bl};
}
解读
- 透视变换是关键步骤:由于相机视角倾斜,装甲板在图像中是梯形,必须校正为矩形才能正确识别。
- 20×28 是经验值,兼顾分辨率和推理速度。太大增加计算量,太小丢失细节。
- MLP(多层感知机)比 CNN 快得多,适合实时场景。560→256→9 的结构非常轻量。
- 置信度阈值 0.8 是高门槛:宁可不识别,也不能误识别。误识别会导致追踪错误目标。
- 此步骤是整个管线的 性能瓶颈,占总延迟约 32%。
Step 6:PnP 位姿解算¶
延迟:~0.1 ms
将装甲板的 4 个 2D 像素角点与已知的 3D 模型点进行 PnP(Perspective-n-Point)求解,得到装甲板在相机坐标系下的 6DoF 位姿。
void DetectorNode::solve_pnp(Armor & armor) {
// 装甲板 3D 模型点(单位:米)
// 小型装甲板: 135mm × 55mm
std::vector<cv::Point3f> object_points;
if (armor.type == SMALL) {
float h = 0.0675f, w = 0.0275f;
object_points = {
{-h, -w, 0}, { h, -w, 0},
{ h, w, 0}, {-h, w, 0}
};
} else { // LARGE: 225mm × 55mm
float h = 0.1125f, w = 0.0275f;
object_points = {
{-h, -w, 0}, { h, -w, 0},
{ h, w, 0}, {-h, w, 0}
};
}
// 装甲板 4 个 2D 像素点
std::vector<cv::Point2f> image_points = {
armor.left.center,
armor.right.center,
// ... 四个角点
};
// PnP 求解
cv::Mat rvec, tvec;
cv::solvePnP(object_points, image_points,
camera_matrix_, dist_coeffs_,
rvec, tvec, false,
cv::SOLVEPNP_IPPE);
// 旋转向量 → 四元数
cv::Mat R;
cv::Rodrigues(rvec, R);
Eigen::Matrix3d eigen_R;
cv::cv2eigen(R, eigen_R);
Eigen::Quaterniond q(eigen_R);
armor.pose.position = {tvec.at<double>(0),
tvec.at<double>(1),
tvec.at<double>(2)};
armor.pose.orientation = {q.w(), q.x(), q.y(), q.z()};
}
解读
- SOLVEPNP_IPPE 专为 4 个共面点设计,比 ITERATIVE 更快更稳定。
- 相机标定参数
camera_matrix_和dist_coeffs_在节点初始化时从 YAML 加载。 - 3D 模型点以装甲板中心为原点,z 轴垂直于装甲板表面。
- 旋转向量 → Rodrigues → 旋转矩阵 → 四元数的转换链是标准流程。
- 0.1ms 的延迟说明 PnP 求解极其高效(4 点闭式解)。
Step 7:发布 /detector/armors¶
延迟:~0 ms
将检测到的所有装甲板打包为 rm_interfaces::msg::ArmorArray 发布。
void DetectorNode::publish_armors(const std::vector<Armor> & armors) {
auto msg = std::make_unique<rm_interfaces::msg::ArmorArray>();
msg->header.stamp = this->now();
msg->header.frame_id = "camera_optical_frame";
for (const auto & armor : armors) {
rm_interfaces::msg::Armor armor_msg;
armor_msg.number = armor.number;
armor_msg.type = (armor.type == SMALL) ? "small" : "large";
armor_msg.pose.position.x = armor.pose.position.x();
armor_msg.pose.position.y = armor.pose.position.y();
armor_msg.pose.position.z = armor.pose.position.z();
armor_msg.pose.orientation.w = armor.pose.orientation.w();
armor_msg.pose.orientation.x = armor.pose.orientation.x();
armor_msg.pose.orientation.y = armor.pose.orientation.y();
armor_msg.pose.orientation.z = armor.pose.orientation.z();
msg->armors.push_back(armor_msg);
}
armors_pub_->publish(std::move(msg));
}
解读
frame_id = "camera_optical_frame"声明了位姿所在的坐标系,tracker 节点依赖此信息做坐标变换。- 一个帧可能检测到 0~N 个装甲板,全部打包在一个消息中。
- 时间戳
this->now()用于后续 TF 查询和延迟补偿。
Step 8:TF2 坐标变换¶
延迟:~0.5 ms
tracker_node 接收 /detector/armors 后,需要将相机坐标系下的位姿变换到里程计坐标系(odom)。这是跟踪和预测的前提——只有在同一坐标系下才能做比较和滤波。
void TrackerNode::armors_callback(
const rm_interfaces::msg::ArmorArray::SharedPtr msg) {
// 查询 TF: camera_optical_frame → odom
geometry_msgs::msg::TransformStamped tf;
try {
tf = tf_buffer_->lookupTransform(
"odom", "camera_optical_frame",
msg->header.stamp, // 使用消息时间戳查询
rclcpp::Duration::from_seconds(0.05));
} catch (tf2::TransformException & ex) {
RCLCPP_WARN(this->get_logger(), "TF failed: %s", ex.what());
return;
}
// 将每个装甲板位姿从相机坐标系变换到 odom 坐标系
for (auto & armor : msg->armors) {
geometry_msgs::msg::PoseStamped pose_in, pose_out;
pose_in.header = msg->header;
pose_in.pose = armor.pose;
tf2::doTransform(pose_in, pose_out, tf);
// pose_out 现在在 odom 坐标系下
process_armor(armor, pose_out.pose);
}
}
解读
- TF 链:
odom → gimbal_link → camera_link → camera_optical_frame。 lookupTransform带时间戳查询,ROS2 会在 TF 缓冲区中插值,保证位姿精度。- 超时 50ms 防止 TF 树未就绪时卡死。
camera_optical_frame的 Z 轴朝前(光轴方向),X 轴朝右,Y 轴朝下——这是相机坐标系惯例。- odom 坐标系以机器人底盘初始位置为原点,Z 轴朝上,适合作为全局跟踪参考。
Step 9:EKF 预测¶
延迟:~0.01 ms
扩展卡尔曼滤波器(EKF)维护一个 11 维状态向量,每个检测周期先执行预测步:用状态转移矩阵 F 将上一时刻的状态外推到当前时刻。
状态向量定义:
| 索引 | 变量 | 含义 |
|---|---|---|
| 0 | x_c | 装甲板中心 x(odom) |
| 1 | v_xc | x 方向速度 |
| 2 | y_c | 装甲板中心 y(odom) |
| 3 | v_yc | y 方向速度 |
| 4 | z_c | 装甲板中心 z(odom) |
| 5 | v_zc | z 方向速度 |
| 6 | yaw | 目标朝向角 |
| 7 | v_yaw | 旋转角速度 |
| 8 | r | 旋转半径 |
| 9 | r_diff | 双半径差(大装甲板) |
| 10 | dz | 装甲板高度偏移 |
void Tracker::predict(double dt) {
// 状态转移矩阵 F
// x' = x + vx * dt (位置 = 位置 + 速度×时间)
// vx' = vx (假设匀速模型)
F_ = Eigen::Matrix<double, 11, 11>::Identity();
F_(0, 1) = dt; // xc += vxc * dt
F_(2, 3) = dt; // yc += vyc * dt
F_(4, 5) = dt; // zc += vzc * dt
F_(6, 7) = dt; // yaw += vyaw * dt
// 预测状态
x_ = F_ * x_;
// 预测协方差
P_ = F_ * P_ * F_.transpose() + Q_;
}
解读
- 匀速模型(Constant Velocity, CV)是最简单的运动假设。对于 RoboMaster 赛场上的目标,匀速模型已经足够好——目标加速度小,帧间时间短。
- 为什么需要 r 和 r_diff?因为装甲板绑在旋转云台上,运动轨迹是圆形而非直线。状态中的 r 描述旋转半径,可以预测装甲板的圆周运动。
yaw和v_yaw用于预测目标朝向变化,这对于大型装甲板(不同 yaw 看到不同面)尤其重要。- Q 是过程噪声协方差矩阵,反映运动模型的不确定性。Q 越大,滤波器越信任观测。
Step 10:数据关联¶
延迟:~0.1 ms
将当前帧检测到的装甲板与已有的跟踪器进行匹配。采用球坐标角度差作为匹配度量,最近的候选优先。
void Tracker::assign(const std::vector<Armor> & detections) {
// 从 EKF 状态预测装甲板在球坐标中的位置
double h_armor_x = x_(0); // 预测的 x
double h_armor_y = x_(2); // 预测的 y
double h_armor_z = x_(4); // 预测的 z
// 计算每个检测与预测位置的角度误差
std::vector<double> costs;
for (const auto & det : detections) {
double dx = det.pose.position.x - h_armor_x;
double dy = det.pose.position.y - h_armor_y;
double dz = det.pose.position.z - h_armor_z;
double dist = std::sqrt(dx*dx + dy*dy + dz*dz);
// 球坐标角度误差
double cost = std::atan2(dist,
std::sqrt(h_armor_x*h_armor_x +
h_armor_y*h_armor_y +
h_armor_z*h_armor_z));
costs.push_back(cost);
}
// 取角度误差最小的检测作为匹配
auto min_it = std::min_element(costs.begin(), costs.end());
if (min_it != costs.end() && *min_it < max_match_angle_) {
matched_idx_ = std::distance(costs.begin(), min_it);
} else {
matched_idx_ = -1; // 无匹配
}
}
解读
- 球坐标角度误差比欧氏距离更适合远近变化大的场景——远处 1m 的误差和近处 1m 的误差不应同等对待。
- 如果没有匹配到任何检测,进入"未观测"模式,只执行预测不更新。
max_match_angle_通常设为 0.3~0.5 rad,过大会错误关联其他目标。- 数据关联是多目标跟踪的核心问题。这里简化为单目标一对一匹配;多目标场景需要匈牙利算法。
Step 11:EKF 更新¶
延迟:~0.01 ms
当数据关联成功匹配到一个检测时,执行卡尔曼更新步:计算卡尔曼增益 K,修正状态估计,更新协方差矩阵。
void Tracker::update(const Armor & detection) {
// 观测向量 z: 检测到的装甲板位置
Eigen::Vector3d z;
z << detection.pose.position.x,
detection.pose.position.y,
detection.pose.position.z;
// 观测函数 h(x): 从状态预测观测
// 直接观测装甲板中心坐标
Eigen::Vector3d h_x;
h_x << x_(0), x_(2), x_(4);
// 观测矩阵 H (3×11)
Eigen::Matrix<double, 3, 11> H = Eigen::Matrix<double, 3, 11>::Zero();
H(0, 0) = 1.0; // z_x → x_c
H(1, 2) = 1.0; // z_y → y_c
H(2, 4) = 1.0; // z_z → z_c
// 新息(残差)
Eigen::Vector3d y = z - h_x;
// 新息协方差
Eigen::Matrix3d S = H * P_ * H.transpose() + R_;
// 卡尔曼增益: K = P * H' * S^{-1}
Eigen::Matrix<double, 11, 3> K = P_ * H.transpose() * S.inverse();
// 状态更新: x = x + K * y
x_ = x_ + K * y;
// 协方差更新 (Joseph 形式,数值更稳定)
Eigen::Matrix<double, 11, 11> I =
Eigen::Matrix<double, 11, 11>::Identity();
Eigen::Matrix<double, 11, 11> IKH = I - K * H;
P_ = IKH * P_ * IKH.transpose() + K * R_ * K.transpose();
}
解读
- Joseph 形式的协方差更新
P = (I-KH)P(I-KH)' + KRK'比简单形式P = (I-KH)P数值更稳定,保证 P 始终半正定。 - 观测矩阵 H 是 3×11,说明我们只观测位置(x,y,z),不直接观测速度、朝向等——这些通过滤波器间接推断。
- R 是观测噪声协方差,反映检测器的精度。R 越小,滤波器越信任检测结果。
- 卡尔曼增益 K 在"信任模型"和"信任观测"之间自动权衡。
Step 12:状态机决策¶
延迟:~0 ms
跟踪器使用状态机管理生命周期,防止误检测导致的误追踪,也处理目标临时丢失的情况。
enum class State { LOST, DETECTING, TRACKING, TEMP_LOST };
void Tracker::update_state(bool detected) {
switch (state_) {
case State::LOST:
if (detected) {
detect_count_++;
if (detect_count_ >= 5) { // 连续5帧检测到
state_ = State::TRACKING;
detect_count_ = 0;
} else {
state_ = State::DETECTING;
}
}
break;
case State::DETECTING:
if (detected) {
detect_count_++;
if (detect_count_ >= 5) {
state_ = State::TRACKING;
}
} else {
detect_count_ = 0;
state_ = State::LOST;
}
break;
case State::TRACKING:
if (!detected) {
lost_time_ = this->now();
state_ = State::TEMP_LOST;
}
break;
case State::TEMP_LOST:
if (detected) {
state_ = State::TRACKING;
} else if ((this->now() - lost_time_).seconds() > 1.0) {
state_ = State::LOST; // 超过1秒丢失
reset();
}
break;
}
}
状态转移图:
解读
- DETECTING 需要连续 5 帧:防止单帧误检测触发追踪。5 帧约 166ms(30fps),足够过滤偶发误检。
- TEMP_LOST 允许 1 秒丢失:比赛中目标可能被障碍物短暂遮挡,1 秒内保持追踪可避免频繁丢失/重捕。
- LOST → DETECTING → TRACKING 的三级状态比简单的"有/无"更鲁棒。
reset()清空 EKF 状态和协方差,准备接受新目标。
Step 13:发布 /tracker/target¶
延迟:~0 ms
状态机处于 TRACKING 或 TEMP_LOST 时,将 EKF 滤波后的目标状态发布为 Target.msg。
void TrackerNode::publish_target() {
if (state_ != State::TRACKING &&
state_ != State::TEMP_LOST) return;
auto msg = std::make_unique<rm_interfaces::msg::Target>();
msg->header.stamp = this->now();
msg->header.frame_id = "odom";
// 位置和速度
msg->position.x = x_(0); // xc
msg->position.y = x_(2); // yc
msg->position.z = x_(4); // zc
msg->velocity.x = x_(1); // vxc
msg->velocity.y = x_(3); // vyc
msg->velocity.z = x_(5); // vzc
// 朝向
msg->yaw = x_(6); // yaw
msg->v_yaw = x_(7); // vyaw
// 装甲板几何参数
msg->radius_1 = x_(8); // r
msg->radius_2 = x_(8) + x_(9); // r + r_diff
msg->dz = x_(10); // dz
target_pub_->publish(std::move(msg));
}
解读
radius_1和radius_2对应大型装甲板的两个旋转半径——大装甲板在云台上左右不对称,两个半径描述了实际运动轮廓。dz描述装甲板相对于云台旋转中心的高度偏移,用于弹道解算时预测装甲板的实际高度。- 发布频率与检测帧率一致(~30Hz),TEMP_LOST 状态下仍在发布最后一次有效状态。
Step 14:弹道解算¶
延迟:~0.1 ms
solver_node 接收 /tracker/target,基于目标的位置和速度,通过抛物线/空气阻力模型计算弹道,迭代求解 pitch 补偿角度,使子弹命中目标。
struct AimPoint {
double x, y, z; // 预测命中的目标位置
double pitch, yaw; // 发射角度
};
AimPoint SolverNode::auto_solve_trajectory(
const rm_interfaces::msg::Target & target) {
// 1. 预测目标在未来子弹飞行时间内的位置
double fly_time = estimate_fly_time(target);
double pred_x = target.position.x + target.velocity.x * fly_time;
double pred_y = target.position.y + target.velocity.y * fly_time;
double pred_z = target.position.z + target.velocity.z * fly_time;
// 2. 选择瞄准点(choose_aim_point)
// 对于旋转目标,选择最近的装甲板位置
double aim_x = pred_x, aim_y = pred_y, aim_z = pred_z;
choose_aim_point(target, fly_time, aim_x, aim_y, aim_z);
// 3. 迭代求解 pitch
double yaw = std::atan2(aim_y, aim_x);
double pitch = std::atan2(aim_z,
std::sqrt(aim_x*aim_x + aim_y*aim_y));
for (int i = 0; i < 10; i++) {
double t = estimate_arrival_time(aim_x, aim_y, aim_z, pitch, yaw);
double z_hit = ballistic_model(aim_x, aim_y, pitch, yaw, t);
double z_err = aim_z - z_hit;
if (std::abs(z_err) < 0.001) break; // 精度 1mm
// pitch 补偿修正
pitch += std::atan2(z_err,
std::sqrt(aim_x*aim_x + aim_y*aim_y));
}
return {aim_x, aim_y, aim_z, pitch, yaw};
}
弹道模型:
double SolverNode::ballistic_model(double x, double y,
double pitch, double yaw,
double t) {
// 无空气阻力的简化抛物线
// v0: 弹速(约 25~30 m/s)
// g: 重力加速度 9.81 m/s²
double v_xy = v0_ * std::cos(pitch);
double vz = v0_ * std::sin(pitch);
double dist_xy = std::sqrt(x*x + y*y);
// 飞行时间 = 水平距离 / 水平速度
// z = vz*t - 0.5*g*t²
return vz * t - 0.5 * 9.81 * t * t;
}
解读
- choose_aim_point 是策略核心:旋转目标有多个装甲板交替出现,需要选择最有利于命中的那个。
- 迭代求解通常 3~5 次收敛,因为 pitch 修正幅度逐次减小。
- 实际比赛中可能考虑空气阻力模型(速度衰减),但抛物线模型在 3~5m 近距离足够精确。
- 弹速
v0_需要在赛前标定,误差直接影响 pitch 补偿精度。 - 瞄准点预测需要考虑子弹飞行时间内的目标移动——这是"打提前量"的数学本质。
Step 15:串口发送¶
延迟:~0.5 ms
将解算出的 pitch、yaw 角度和射击指令打包为定长数据包,通过 UART 串口发送给 STM32 电控板。
// 串口数据包格式 (19 字节)
!!! info "简化示意"
以下是端到端走读中的简化版协议,便于理解数据流。完整的协议格式请参考 [ch06 串口协议详解](ch06-architecture/index.md)。
struct SendPacket {
uint8_t header[2]; // 0xAA 0x55 帧头
uint8_t length; // 0x0C = 12 数据长度
float pitch; // 4 字节 IEEE 754
float yaw; // 4 字节 IEEE 754
uint8_t shoot; // 1 字节: 0=不射击, 1=射击
uint8_t padding; // 1 字节对齐填充
uint16_t crc16; // 2 字节 CRC 校验
}; // 总计 2+1+4+4+1+1+2 = 15 字节(可能因对齐有差异)
void SolverNode::send_to_stm32(double pitch, double yaw, bool shoot) {
SendPacket pkt;
pkt.header[0] = 0xAA;
pkt.header[1] = 0x55;
pkt.length = 0x0C;
pkt.pitch = static_cast<float>(pitch);
pkt.yaw = static_cast<float>(yaw);
pkt.shoot = shoot ? 1 : 0;
pkt.padding = 0;
pkt.crc16 = crc16_calc(reinterpret_cast<uint8_t*>(&pkt),
sizeof(pkt) - 2);
serial_.write(reinterpret_cast<uint8_t*>(&pkt), sizeof(pkt));
}
CRC16 校验:
uint16_t crc16_calc(uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int j = 0; j < 8; j++) {
if (crc & 1) crc = (crc >> 1) ^ 0xA001;
else crc >>= 1;
}
}
return crc;
}
解读
- 帧头 0xAA 0x55 用于 STM32 端的帧同步——接收端在数据流中搜索帧头来确定包边界。
- CRC16 校验数据完整性。如果传输中出现位翻转,STM32 校验失败会丢弃该包。
- pitch 和 yaw 使用 float(4字节IEEE 754) 传递弧度值,精度约 6 位有效数字,足够控制需求。
- 串口波特率通常 115200 或 921600,19 字节在 115200 下传输约 1.6ms。
shoot字段在满足连续帧的命中条件时置 1,否则置 0。
Step 16:电控接收¶
延迟:~1 ms(STM32 侧)
STM32 电控板通过 UART 接收数据包,解析 pitch/yaw 角度,驱动云台电机 PID 闭环控制。
// STM32 侧接收解析(简化伪代码)
void USART_IRQHandler(void) {
static uint8_t buf[20];
static uint8_t idx = 0;
uint8_t byte = USART->DR;
buf[idx++] = byte;
// 帧头检测
if (idx == 2 && (buf[0] != 0xAA || buf[1] != 0x55)) {
idx = 0; return;
}
if (idx >= 15) { // 完整包接收完成
SendPacket *pkt = (SendPacket*)buf;
// CRC 校验
uint16_t calc_crc = crc16_calc(buf, 13);
if (calc_crc != pkt->crc16) {
idx = 0; return; // CRC 失败,丢弃
}
// 提取 pitch 和 yaw
float pitch_cmd = pkt->pitch;
float yaw_cmd = pkt->yaw;
uint8_t shoot = pkt->shoot;
// PID 控制云台电机
gimbal_set_angle(pitch_cmd, yaw_cmd);
if (shoot) trigger_shooter();
idx = 0;
}
}
解读
- STM32 端使用中断接收,逐字节存入缓冲区,避免轮询浪费 CPU。
- 帧头 + CRC 双重保护确保数据可靠性。在电磁干扰强的赛场环境中,CRC 校验至关重要。
gimbal_set_angle内部是经典的 PID 闭环:目标角度 → 误差计算 → PID → PWM 占比 → 电机驱动。- 云台电机通常使用 GM6020 或类似的大疆电机,CAN 总线通信。
- 从视觉检测到电机转动,整个链路延迟约 10ms,这意味着目标在延迟期间移动了约 0.3m(以 3m/s 速度),弹道预测的提前量补偿正是为了抵消这个延迟。
延迟构成饼图¶
常见问题排查¶
Q1: 检测到装甲板但 PnP 解算距离异常¶
检查相机标定文件是否正确加载。camera_matrix 和 dist_coeffs 是 PnP 求解的基础,任何偏差都会导致距离估算错误。
Q2: 跟踪器频繁丢失目标¶
可能是 max_match_angle_ 过小或 EKF 的 Q 矩阵过大。目标快速移动时,预测位置和实际位置差距大,角度误差超过阈值导致匹配失败。
Q3: 弹道偏高/偏低¶
检查弹速参数 v0_ 是否与实际一致。弹速随摩擦轮磨损会变化,建议每场比赛前用测速仪校准。
Q4: 红蓝识别错误¶
在逆光或混合光照场景下,颜色判断可能出错。可以尝试在 HSV 空间判断颜色,比单纯的 R-B 通道比较更鲁棒。
小结¶
一帧图像从相机曝光到电控接收,经历了 16 个处理步骤,总延迟约 9~10ms。关键要点:
- 管线化设计:各步骤解耦为独立节点,通过 ROS2 话题通信,便于开发和调试。
- 实时性优先:选择 MLP 而非 CNN、固定阈值而非 OTSU、Joseph 协方差更新——处处为速度优化。
- 鲁棒性设计:状态机防误检、CRC 防传输错误、置信度阈值防误识别。
- 延迟补偿:弹道解算中的提前量预测是命中率的关键,端到端延迟越低,提前量越小,命中率越高。
动手练习
- 尝试在
detector_node中添加处理时间统计(std::chrono),验证每步的实际延迟。 - 修改 MLP 置信度阈值(0.8→0.6),观察识别率和误识别率的变化。
- 在弹道解算中添加空气阻力模型,对比与简单抛物线的差异。