跳转至

端到端走读:一帧图像的旅程

本章以 一帧图像 为线索,从相机曝光到子弹出膛,完整追踪数据在 auto-aim 管线中的每一次变换。 每一步都给出关键代码、延迟估算和数据格式变化,帮助你建立 "数据流" 的全局直觉。


全景流程图

flowchart TD A["📷 相机采集
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 将上一时刻的状态外推到当前时刻。

状态向量定义:

\[ \mathbf{x} = [x_c, v_{x_c}, y_c, v_{y_c}, z_c, v_{z_c}, yaw, v_{yaw}, r, r_{diff}, dz]^T \]
索引 变量 含义
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 描述旋转半径,可以预测装甲板的圆周运动。
  • yawv_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;
    }
}

状态转移图:

stateDiagram-v2 [*] --> LOST LOST --> DETECTING : 检测到目标 DETECTING --> TRACKING : 连续5帧确认 DETECTING --> LOST : 丢失 TRACKING --> TEMP_LOST : 丢失 TEMP_LOST --> TRACKING : 重新检测到 TEMP_LOST --> LOST : 超过1秒

解读

  • 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_1radius_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 速度),弹道预测的提前量补偿正是为了抵消这个延迟。

延迟构成饼图

pie title 端到端延迟构成 (总计 ~9.3ms) "数字识别 MLP" : 3.0 "灯条检测" : 2.0 "图像预处理" : 1.0 "装甲板匹配" : 1.0 "TF2变换" : 0.5 "串口发送" : 0.5 "PnP解算" : 0.1 "数据关联" : 0.1 "弹道解算" : 0.1 "EKF(预测+更新)" : 0.02 "状态机+发布" : 0.01

常见问题排查

Q1: 检测到装甲板但 PnP 解算距离异常

检查相机标定文件是否正确加载。camera_matrixdist_coeffs 是 PnP 求解的基础,任何偏差都会导致距离估算错误。

# 查看标定参数
ros2 param get /detector_node camera_matrix

Q2: 跟踪器频繁丢失目标

可能是 max_match_angle_ 过小或 EKF 的 Q 矩阵过大。目标快速移动时,预测位置和实际位置差距大,角度误差超过阈值导致匹配失败。

Q3: 弹道偏高/偏低

检查弹速参数 v0_ 是否与实际一致。弹速随摩擦轮磨损会变化,建议每场比赛前用测速仪校准。

Q4: 红蓝识别错误

在逆光或混合光照场景下,颜色判断可能出错。可以尝试在 HSV 空间判断颜色,比单纯的 R-B 通道比较更鲁棒。


小结

一帧图像从相机曝光到电控接收,经历了 16 个处理步骤,总延迟约 9~10ms。关键要点:

  1. 管线化设计:各步骤解耦为独立节点,通过 ROS2 话题通信,便于开发和调试。
  2. 实时性优先:选择 MLP 而非 CNN、固定阈值而非 OTSU、Joseph 协方差更新——处处为速度优化。
  3. 鲁棒性设计:状态机防误检、CRC 防传输错误、置信度阈值防误识别。
  4. 延迟补偿:弹道解算中的提前量预测是命中率的关键,端到端延迟越低,提前量越小,命中率越高。

动手练习

  • 尝试在 detector_node 中添加处理时间统计(std::chrono),验证每步的实际延迟。
  • 修改 MLP 置信度阈值(0.8→0.6),观察识别率和误识别率的变化。
  • 在弹道解算中添加空气阻力模型,对比与简单抛物线的差异。