跳转至

第6章 系统架构总览

你将在本章学到什么

本章是整个自瞄系统的 鸟瞰图。你将理解:

  • 数据如何从相机流向电控
  • 5个ROS2包各自负责什么
  • 节点之间通过哪些Topic通信
  • 为什么需要TF2坐标变换
  • Launch文件如何把一切串起来
  • 串口协议的每一个字节是什么意思

读本章之前的准备

建议先完成 第4章 ROS2核心概念 的学习,至少理解 节点(Node)话题(Topic)组件(Component)Launch文件 的基本概念。


1. 系统总览

1.1 一句话版本

自瞄系统做的事: 相机拍照 → 检测装甲板 → 跟踪目标 → 通过串口把目标信息发给STM32 → 电控端解算弹道并控制云台射击。

1.2 数据流全景图

flowchart LR subgraph 硬件层 CAM["📷 海康工业相机
MVS-CE/CH系列"] STM32["⚡ STM32主控板
电控端"] GIMBAL["🤖 云台电机
Yaw + Pitch"] end subgraph 视觉端 [ROS2 视觉端 (工控机)] direction TB DET["🔍 armor_detector
装甲板检测"] TRK["🎯 armor_tracker
目标跟踪"] SRV["🔌 rm_serial_driver
串口通信"] end CAM -- "/image_raw
图像帧" --> DET DET -- "/detector/armors
检测到的装甲板" --> TRK TRK -- "/tracker/target
跟踪目标" --> SRV SRV -- "串口发送
SendPacket" --> STM32 STM32 -- "串口接收
ReceivePacket" --> SRV STM32 --> GIMBAL

1.3 每个节点一句话

节点 职责 输入 输出
海康相机 采集图像帧 光学图像 /image_raw (sensor_msgs/Image)
armor_detector 检测图像中的装甲板 /image_raw /detector/armors (Armors)
armor_tracker 跟踪目标,估计运动状态 /detector/armors + TF2 /tracker/target (Target)
rm_serial_driver 串口通信,收发电控数据 /tracker/target + 串口 串口SendPacket → STM32
rm_vision_bringup 启动所有节点并配置参数 launch文件 各节点正常运行

理解延迟的原因

从相机拍照到STM32收到数据,整个链路的延迟大约 10-20ms。其中检测约5-8ms(OpenVINO推理),跟踪约1ms(EKF预测),串口传输约1ms。这个延迟需要在跟踪器中通过状态外推来补偿——后续章节会详细解释。


2. 五个ROS2包详解

rm_vision项目由5个ROS2功能包组成,每个包有明确的分工:

rm_vision/
├── auto_aim_interfaces/     # 自定义消息接口
├── armor_detector/          # 装甲板检测
├── armor_tracker/           # 目标跟踪
├── rm_serial_driver/        # 串口通信
└── rm_vision_bringup/       # 启动配置

2.1 auto_aim_interfaces —— 消息定义

作用: 定义自瞄系统中所有节点之间传递的 自定义消息类型。没有这个包,节点之间无法交换装甲板信息。

这个包没有代码逻辑

auto_aim_interfaces 只包含 .msg 文件和 CMakeLists.txt,不包含任何 C++ 源文件。它编译后会生成 C++ 头文件,供其他包 #include 使用。


3. 代码走读:消息接口

在阅读任何算法代码之前,我们先理解节点之间传递的数据结构。消息定义就是节点之间的"合同"——发布者和订阅者必须对字段含义达成一致。

3.1 Armor.msg —— 单块装甲板

auto_aim_interfaces/msg/Armor.msg
string number
string type
float32 distance_to_image_center
geometry_msgs/Pose pose

逐行解读

  • string number:数字识别结果,如 "3" 表示 3 号步兵,"outpost" 表示前哨站。与旧版不同,2027版使用字符串而非 uint8,可以表示更复杂的标识符(如哨兵、前哨站等特殊单位)。
  • string type:装甲板类型,"small""large"。小装甲是普通步兵,大装甲是英雄机器人。这个信息影响弹道参数选择。
  • float32 distance_to_image_center:装甲板中心到图像中心的归一化距离(0.0 ~ 1.0)。当画面中有多个装甲板时,优先选择离画面中心最近的目标,因为那通常是主目标。
  • geometry_msgs/Pose pose:装甲板的完整位姿——包含 3D 位置 position (x, y, z) 和四元数朝向 orientation (x, y, z, w)。注意这里使用了 Pose 而非简单的 Point,意味着不仅知道装甲板在哪,还知道它朝哪个方向。这是 PnP 解算的输出结果,坐标系为 camera_optical_frame

Pose vs Point 的区别

geometry_msgs/Point 只有 (x, y, z) 三个字段;geometry_msgs/pose 多了一个 orientation 四元数。2027版用 Pose 的原因是:电控端解算弹道时,装甲板的朝向会影响命中区域的计算。

3.2 Armors.msg —— 装甲板列表

auto_aim_interfaces/msg/Armors.msg
std_msgs/Header header
Armor[] armors

逐行解读

  • std_msgs/Header header:标准消息头,包含时间戳 stamp(图像采集时间)和坐标系 ID frame_id(通常是 "camera_optical_frame")。时间戳至关重要——跟踪器需要用它来计算帧间时间差,用于 EKF 的状态预测。
  • Armor[] armors:检测到的所有装甲板的数组。一帧图像中可能检测到 0 到多个装甲板。当机器人背对相机时,可能检测到 2 块装甲板(前后各一)。当阵亡时,可能检测到 0 块。

3.3 Target.msg —— 跟踪目标

这是 tracker → serial_driver 之间传递的核心消息:

auto_aim_interfaces/msg/Target.msg
std_msgs/Header header
bool tracking
string id
int32 armors_num
geometry_msgs/Point position
geometry_msgs/Vector3 velocity
float64 yaw
float64 v_yaw
float64 radius_1
float64 radius_2
float64 dz

逐行解读

  • std_msgs/Header header:消息头,时间戳为跟踪器预测的目标时刻。
  • bool tracking:是否正在跟踪目标。false 表示目标丢失,电控端应保持上一帧的瞄准状态或搜索目标。这是最关键的状态标志。
  • string id:目标机器人 ID,与 Armor.msg 的 number 对应。如 "3" 表示正在跟踪 3 号步兵。电控端根据 ID 选择不同的弹道参数表。
  • int32 armors_num:目标装甲板总数。4 块 = 普通步兵,3 块 = 前哨站,2 块 = 小陀螺状态。电控端据此建立目标的旋转几何模型。
  • geometry_msgs/Point position:目标中心在 odom 坐标系下的 3D 位置 (x, y, z),单位米。这是 EKF 状态向量中的位置分量。
  • geometry_msgs/Vector3 velocity:目标中心的速度 (vx, vy, vz),单位 m/s。这是 EKF 的速度估计值,用于预测弹丸飞行期间目标的位移。
  • float64 yaw:目标朝向角(弧度)。想象从上方俯视,目标车头指向的角度。结合 radius_1radius_2 可以算出每块装甲板的具体位置。
  • float64 v_yaw:目标角速度(弧度/秒)。正数表示逆时针旋转,负数表示顺时针。用于预测目标旋转到哪个面。
  • float64 radius_1:目标中心到前/后装甲板的水平距离(米)。这是目标几何模型的关键参数。
  • float64 radius_2:目标中心到左/右装甲板的水平距离(米)。通常 r1 != r2,因为机器人底盘不是正方形。
  • float64 dz:上下两层装甲板的高度差(米)。英雄机器人(大装甲)有上下两层装甲板,dz > 0;普通步兵 dz = 0

为什么Target有这么多字段?

因为电控端需要做 弹道解算(第10章详解)。它不只需要目标"在哪里",还需要知道"往哪跑"、"跑多快"、"车多大"——这样才能预测弹丸飞行时间内目标移动到哪里。如果视觉端只发一个 (x, y, z),电控端根本没法做预测。


4. 代码走读:Launch启动文件

4.1 common.py —— 共享启动配置

在看主 Launch 文件之前,先理解它依赖的 common.py 模块。这个文件被所有 Launch 文件共享,提供了统一的参数加载和节点定义:

rm_vision_bringup/launch/common.py
import os
import yaml

from ament_index_python.packages import get_package_share_directory
from launch.substitutions import Command
from launch_ros.actions import Node

launch_params = yaml.safe_load(open(os.path.join(
    get_package_share_directory('rm_vision_bringup'), 'config', 'launch_params.yaml')))

robot_description = Command(['xacro ', os.path.join(
    get_package_share_directory('rm_gimbal_description'), 'urdf', 'rm_gimbal.urdf.xacro'),
    ' xyz:=', launch_params['odom2camera']['xyz'], ' rpy:=', launch_params['odom2camera']['rpy']])

robot_state_publisher = Node(
    package='robot_state_publisher',
    executable='robot_state_publisher',
    parameters=[{'robot_description': robot_description,
                 'publish_frequency': 1000.0}]
)

node_params = os.path.join(
    get_package_share_directory('rm_vision_bringup'), 'config', 'node_params.yaml')

tracker_node = Node(
    package='armor_tracker',
    executable='armor_tracker_node',
    output='both',
    emulate_tty=True,
    parameters=[node_params],
    ros_arguments=['--log-level', 'armor_tracker:='+launch_params['tracker_log_level']],
)

逐行解读

导入部分(第1-6行): - yaml:用于解析 YAML 配置文件 - get_package_share_directory:获取 ROS2 包的 share 目录路径(安装后配置文件的位置) - Command:Launch 框架的字符串拼接工具,用于动态生成 xacro 命令 - Node:Launch 中定义 ROS2 节点的 Action

launch_params 加载(第8-9行): - 从 rm_vision_bringup 包的 config/launch_params.yaml 读取所有启动参数 - 这些参数控制相机类型、日志级别、串口路径等运行时选项

robot_description 生成(第11-13行): - 用 xacro 命令解析 URDF 模型文件 rm_gimbal.urdf.xacro - xyzrpy 参数表示相机相对于云台的安装偏移——这会在 common.py 中被 Command 拼接成完整的 xacro 命令 - 生成的 URDF 描述了 gimbal_link → camera_link → camera_optical_frame 的静态 TF 变换

robot_state_publisher(第15-20行): - 启动 robot_state_publisher 节点,它会根据 URDF 广播静态 TF - publish_frequency: 1000.0 表示以 1000Hz 发布 TF——远高于一般的 50Hz,是为了保证 tracker 查询 TF 时延迟极低

tracker_node(第25-32行): - 定义 armor_tracker 节点,加载 node_params.yaml 中的参数 - emulate_tty=True:模拟终端,确保日志颜色输出正常 - ros_arguments:通过命令行覆盖日志级别,方便调试时动态调整

4.2 vision_bringup.launch.py —— 主启动文件

这是整个自瞄系统的入口文件,负责按正确的顺序启动所有节点:

rm_vision_bringup/launch/vision_bringup.launch.py
import os
import sys
from ament_index_python.packages import get_package_share_directory
sys.path.append(os.path.join(get_package_share_directory('rm_vision_bringup'), 'launch'))

逐行解读

  • 第1-2行:导入标准库 os(路径操作)和 sys(修改 Python 模块搜索路径)
  • 第3行:获取 rm_vision_bringup 包的 share 目录
  • 第4行:将 launch/ 目录加入 Python 路径,这样后续 from common import ... 才能找到 common.py 模块。这是一个常见的 Launch 文件技巧。
def generate_launch_description():

    from common import node_params, launch_params, robot_state_publisher, tracker_node
    from launch_ros.descriptions import ComposableNode
    from launch_ros.actions import ComposableNodeContainer, Node
    from launch.actions import TimerAction, Shutdown
    from launch import LaunchDescription

逐行解读

  • generate_launch_description():ROS2 Launch 系统的入口函数,必须返回一个 LaunchDescription 对象
  • from common import ...:在函数内部导入 common.py 中预定义的变量。在函数内而非文件顶部导入,是因为 Launch 系统会在导入阶段就执行模块级代码,放在函数内可以延迟执行
  • ComposableNode:可组合节点描述——定义一个可以和其他 Component 共享进程的节点
  • ComposableNodeContainer:组件容器——一个进程,多个 Component 在其中共享内存
  • TimerAction:定时触发动作,用于延迟启动某些节点
  • Shutdown:当节点退出时触发整个 Launch 系统关闭
    def get_camera_node(package, plugin):
        return ComposableNode(
            package=package,
            plugin=plugin,
            name='camera_node',
            parameters=[node_params],
            extra_arguments=[{'use_intra_process_comms': True}]
        )

逐行解读

  • 第1行:工厂函数,根据包名和插件名创建相机组件节点。支持海康(hik_camera)和迈德威视(mindvision_camera)两种相机
  • package:相机驱动所在的 ROS2 包名
  • plugin:插件类的全限定名,如 hik_camera::HikCameraNode,这是 Component 注册时用的名字
  • name='camera_node':节点在 ROS2 图中的名称
  • parameters=[node_params]:加载 node_params.yaml 中的参数(曝光时间、增益等)
  • use_intra_process_comms: True关键优化! 开启进程内通信,使该节点与同容器内的其他节点共享内存,避免图像数据的序列化/拷贝
    def get_camera_detector_container(camera_node):
        return ComposableNodeContainer(
            name='camera_detector_container',
            namespace='',
            package='rclcpp_components',
            executable='component_container',
            composable_node_descriptions=[
                camera_node,
                ComposableNode(
                    package='armor_detector',
                    plugin='rm_auto_aim::ArmorDetectorNode',
                    name='armor_detector',
                    parameters=[node_params],
                    extra_arguments=[{'use_intra_process_comms': True}]
                )
            ],
            output='both',
            emulate_tty=True,
            ros_arguments=['--ros-args', '--log-level',
                           'armor_detector:='+launch_params['detector_log_level']],
            on_exit=Shutdown(),
        )

逐行解读

  • name='camera_detector_container':容器进程名称,便于 ros2 node list 时识别
  • namespace='':使用全局命名空间(无前缀)
  • executable='component_container':使用单线程组件容器(component_container_mt 为多线程版)
  • composable_node_descriptions:要在容器中加载的组件列表
    • camera_node:上面创建的相机组件——在进程内直接传递图像指针
    • ArmorDetectorNode:检测器组件,与相机共享进程
    • 两个节点都开启了 use_intra_process_comms,形成 零拷贝通信链路
  • output='both':同时输出到终端和日志文件
  • emulate_tty=True:模拟终端,保留彩色日志输出
  • ros_arguments:通过 --log-level 设置检测器的日志级别,值来自 launch_params.yaml
  • on_exit=Shutdown():如果容器进程意外退出,关闭整个 Launch 系统——因为没有检测器,整个自瞄链路就断了,不如直接重启

为什么相机和检测器要装进同一个进程?

这是整个系统最重要的性能优化。

不做Component的代价: 相机发布图像 → 序列化为字节流 → 跨进程传输 → 检测器反序列化为cv::Mat → 开始处理。一张640x480的BGR图约900KB,每秒60帧就是 54MB/s 的序列化开销。

用Component零拷贝: 相机和检测器在同一进程,图像通过 shared_ptr 直接传递,零拷贝、零序列化、零跨进程传输。延迟降低约2-3ms,CPU占用降低约20%。

use_intra_process_comms: True 就是开启这个优化的关键配置。

    hik_camera_node = get_camera_node('hik_camera', 'hik_camera::HikCameraNode')
    mv_camera_node = get_camera_node('mindvision_camera', 'mindvision_camera::MVCameraNode')

    if (launch_params['camera'] == 'hik'):
        cam_detector = get_camera_detector_container(hik_camera_node)
    elif (launch_params['camera'] == 'mv'):
        cam_detector = get_camera_detector_container(mv_camera_node)

逐行解读

  • 第1-2行:分别创建海康和迈德威视两种相机的组件节点描述
  • 第4-7行:根据 launch_params.yaml 中的 camera 字段选择使用哪种相机。'hik' = 海康,'mv' = 迈德威视。选好后,将相机节点和检测器节点打包进同一个组件容器
  • 这种工厂模式使得切换相机只需改配置文件,无需修改代码
    serial_driver_node = Node(
        package='rm_serial_driver',
        executable='rm_serial_driver_node',
        name='serial_driver',
        output='both',
        emulate_tty=True,
        parameters=[node_params],
        on_exit=Shutdown(),
        ros_arguments=['--ros-args', '--log-level',
                       'serial_driver:='+launch_params['serial_log_level']],
    )

    delay_serial_node = TimerAction(
        period=1.5,
        actions=[serial_driver_node],
    )

    delay_tracker_node = TimerAction(
        period=2.0,
        actions=[tracker_node],
    )

逐行解读

serial_driver_node(第1-10行): - 这是一个 普通节点(不是 Component),因为串口驱动需要独立进程——它有专用的 IO 线程和阻塞式读取 - on_exit=Shutdown():串口断开意味着与电控失联,必须触发系统重启 - 日志级别由 launch_params 控制

delay_serial_node(第12-15行): - TimerAction(period=1.5, ...):延迟 1.5 秒 后再启动串口驱动 - 原因:STM32 上电初始化需要时间,过早打开串口会连接失败

delay_tracker_node(第16-19行): - 延迟 2.0 秒 后启动跟踪器 - 原因:tracker 需要等待串口驱动广播 odom → gimbal_link 的 TF 变换后才能正常工作

    decision_node = Node(
        package='rm_decision',
        executable='decision_node',
        name='decision_node',
        output='both',
        emulate_tty=True,
        parameters=[{
            'hp_threshold': 200.0,
            'hp_recovery_ratio': 0.90,
            'nav_action_name': '/red_standard_robot1/navigate_to_pose',
            'nav_action_fallback': '/navigate_to_pose',
        }],
        remappings=[
            ('game_status', '/serial/game_status'),
            ('robot_status', '/serial/game_robot_status'),
        ],
    )

    delay_decision_node = TimerAction(
        period=2.5,
        actions=[decision_node],
    )

逐行解读

decision_node(第1-15行): - rm_decision 是一个独立的决策节点,负责根据比赛状态做出自主决策 - hp_threshold: 200.0:血量阈值——当己方血量低于 200 时触发特定行为(如撤退) - hp_recovery_ratio: 0.90:血量恢复比例——血量恢复到 90% 才重新进攻 - nav_action_name:导航 Action 的名称,用于发送导航目标 - remappings:话题重映射——将 game_status 重映射到 /serial/game_status,从串口驱动接收裁判系统数据

delay_decision_node(第17-20行): - 延迟 2.5 秒 启动,是所有节点中最晚的 - 原因:决策节点依赖裁判系统数据,需要等串口驱动先启动并开始接收数据

    return LaunchDescription([
        robot_state_publisher,
        cam_detector,
        delay_serial_node,
        delay_tracker_node,
        delay_decision_node,
    ])

逐行解读

  • robot_state_publisher:第一个启动,立即广播静态 TF(gimbal_link → camera_link → camera_optical_frame
  • cam_detector:第二个启动(无延迟),相机 + 检测器是整条链路的源头
  • delay_serial_node:1.5 秒后启动串口驱动
  • delay_tracker_node:2.0 秒后启动跟踪器
  • delay_decision_node:2.5 秒后启动决策节点
  • 启动顺序遵循 依赖链:先源头 → 再通信 → 再消费数据 → 最后决策

4.3 启动顺序时序图

sequenceDiagram participant Launch as Launch系统 participant RSP as robot_state_publisher participant Container as camera_detector_container participant Serial as rm_serial_driver participant Tracker as armor_tracker participant Decision as rm_decision Launch->>RSP: t=0s: 广播静态TF Launch->>Container: t=0s: 启动容器
(相机+检测器) Container->>Container: 相机初始化 (~0.5s) Container->>Container: 检测器加载模型 (~1s) Container->>Container: 开始出图 + 检测 Note over Launch: 等待 1.5 秒... Launch->>Serial: t=1.5s: 启动串口驱动 Serial->>Serial: 打开串口,开始通信 Note over Launch: 再等待 0.5 秒... Launch->>Tracker: t=2.0s: 启动跟踪器 Tracker->>Tracker: 订阅 /detector/armors Tracker->>Tracker: 等待TF2变换可用 Note over Launch: 再等待 0.5 秒... Launch->>Decision: t=2.5s: 启动决策节点 Note over Decision: 系统进入完整工作状态

4.4 为什么这样设计延迟?

节点 延迟 原因
robot_state_publisher 0s (立即) 静态 TF 必须最先可用
camera + detector 0s (立即) 是整个链路的源头,必须先启动
serial_driver 1.5s 需要等STM32上电初始化完成,串口才能正常通信
tracker 2.0s 需要等串口驱动广播 odom→gimbal_link 的TF后才能正常工作
decision 2.5s 需要等串口驱动开始接收裁判系统数据

如果顺序不对会怎样?

  • 如果tracker先于serial_driver启动:tracker查不到 odom→gimbal_link 的TF变换,坐标变换失败,一直报 Lookup would require extrapolation into the past 警告
  • 如果serial_driver先于STM32初始化完成:串口打开失败,直接崩溃或不断重连
  • 所以正确的顺序是:先URDF + 相机检测器 → 再串口 → 再tracker → 最后决策

5. Topic关系图

5.1 完整Topic拓扑

下图展示了自瞄系统运行时 所有Topic的发布/订阅关系

graph TB subgraph 海康相机驱动 CAM["📷 海康相机节点
hik_camera_node"] end subgraph armor_detector DET["🔍 DetectorNode"] end subgraph armor_tracker TRK["🎯 ArmorTrackerNode"] end subgraph rm_serial_driver SRD["🔌 SerialDriverNode"] end subgraph 可视化 / 调试 RVIZ["🖥️ RViz2"] RQT["📊 rqt_image_view"] end %% 核心数据流 CAM -- "/image_raw
sensor_msgs/Image" --> DET DET -- "/detector/armors
Armors.msg" --> TRK TRK -- "/tracker/target
Target.msg" --> SRD %% 串口双向 SRD -- "串口发送 SendPacket" --> STM32["⚡ STM32"] STM32 -- "串口接收 ReceivePacket" --> SRD %% TF 广播 SRD -. "TF: odom→gimbal_link" .-> TRK URDF["📦 URDF模型"] -. "TF: gimbal_link→camera_link
→camera_optical_frame" .-> TRK %% 调试话题 DET -- "/detector/result
Image (标注后)" --> RQT TRK -- "/tracker/marker
MarkerArray" --> RVIZ DET -- "/detector/light
Image (灯条)" --> RQT

5.2 每个Topic的详细参数

Topic 消息类型 发布者 订阅者 QoS 频率
/image_raw sensor_msgs/msg/Image 海康相机 detector BEST_EFFORT, 深度1 60-120Hz
/camera_info sensor_msgs/msg/CameraInfo 海康相机 detector, tracker BEST_EFFORT 60-120Hz
/detector/armors auto_aim_interfaces/msg/Armors detector tracker RELIABLE, 深度10 60-120Hz
/detector/result sensor_msgs/msg/Image detector 调试用 BEST_EFFORT 同帧率
/detector/light sensor_msgs/msg/Image detector 调试用 BEST_EFFORT 同帧率
/tracker/target auto_aim_interfaces/msg/Target tracker serial_driver RELIABLE, 深度1 60-120Hz
/tracker/marker visualization_msgs/msg/MarkerArray tracker RViz RELIABLE 同帧率
/serial/receive 自定义 serial_driver 可选 RELIABLE ~100Hz
/serial/game_status auto_aim_interfaces/msg/GameStatus serial_driver decision RELIABLE ~10Hz

为什么 /image_raw 用 BEST_EFFORT 而不是 RELIABLE?

图像数据量大、帧率高,如果用 RELIABLE QoS,一旦处理不过来就会堆积消息,导致延迟越来越大。BEST_EFFORT 允许丢帧——丢了就丢了,下一帧更重要。这是实时视觉系统的常见做法。

为什么 /detector/armors 用 RELIABLE?

装甲板检测结果是小数据量消息(每个Armor只有几十字节),丢失一帧可能意味着丢失目标。而且检测频率和处理频率是一致的(同一个pipeline),不太可能堆积。


6. TF2坐标系树

6.1 坐标系层次结构

graph TD ODOM["🌍 odom
世界/惯性坐标系
(固定不动)"] GIMBAL["🎯 gimbal_link
云台坐标系
(随云台旋转)"] CAMERA["📷 camera_link
相机物理坐标系
(相机固定安装位置)"] OPTIC["👁️ camera_optical_frame
相机光学坐标系
(图像坐标系)"] ODOM -->|"旋转 R
来源: serial_driver
根据STM32上报的
Yaw/Pitch/Roll" | GIMBAL GIMBAL -->|"固定变换 T
来源: URDF
安装偏移" | CAMERA CAMERA -->|"固定变换 R·T
来源: URDF
XYZ→光学轴旋转" | OPTIC style ODOM fill:#4CAF50,color:#fff style GIMBAL fill:#2196F3,color:#fff style CAMERA fill:#FF9800,color:#fff style OPTIC fill:#F44336,color:#fff

6.2 每个坐标系的含义

坐标系 原点位置 Z轴方向 特点
odom 系统启动时的机器人位置 指向上方 固定不动,是全局参考系
gimbal_link 云台旋转中心 指向上方 随云台转动,Yaw/Pitch/Roll会变
camera_link 相机物理位置 指向前方(镜头方向) 相对于gimbal_link固定安装
camera_optical_frame 相机光心 指向光轴方向(前方) OpenCV/图像坐标系

6.3 为什么需要 camera_optical_frame?

这是初学者最容易困惑的地方。关键在于 两个坐标系的Z轴方向不同

graph LR subgraph camera_link [camera_link 物理坐标系] CL_X["X → 前(相机朝向)"] CL_Y["Y → 左"] CL_Z["Z → 上"] end subgraph optical [camera_optical_frame 光学坐标系] O_X["X → 右"] O_Y["Y → 下"] O_Z["Z → 前(光轴方向)"] end
坐标系 X轴 Y轴 Z轴 用途
camera_link 上方 ROS2 标准约定(X前Y左Z上)
camera_optical_frame 前方(光轴) OpenCV/视觉算法

这个区别很关键

在 ROS2 标准中,camera_link 的 X 轴朝前(即相机朝向方向),Z 朝上。但 OpenCV 的坐标系是 X 右 Y 下 Z 前(光轴方向)。camera_optical_frame 通过一个旋转,让Z轴重新指向光轴方向。检测器的PnP解算结果(Armor.pose)是在 camera_optical_frame 下的——因为OpenCV的坐标系就是X右Y下Z前。如果不做这个变换,PnP算出来的3D坐标方向就是错的。

6.4 TF变换的来源

flowchart TB subgraph 静态变换 [静态TF(不变)] URDF["📦 URDF文件
描述机器人模型"] URDF --> TF1["gimbal_link → camera_link
安装位置偏移"] URDF --> TF2["camera_link → camera_optical_frame
坐标轴旋转"] end subgraph 动态变换 [动态TF(实时变化)] SERIAL["🔌 serial_driver节点"] SERIAL --> TF3["odom → gimbal_link
根据STM32上报的姿态"] end subgraph 使用者 TRACKER["🎯 armor_tracker"] end TF1 --> TRACKER TF2 --> TRACKER TF3 --> TRACKER

总结:

变换 来源 更新频率 是否需要运行时变化
odom → gimbal_link serial_driver 动态广播 ~100Hz 是(云台在转)
gimbal_link → camera_link URDF 静态广播 一次 否(安装位置固定)
camera_link → camera_optical_frame URDF 静态广播 一次 否(坐标约定固定)

6.5 为什么需要坐标变换?

这是一道贯穿整个自瞄系统的关键题:

sequenceDiagram participant DET as detector participant TF2 as TF2系统 participant TRK as tracker participant EKF as EKF滤波器 Note over DET: 检测出装甲板位置
在 camera_optical_frame 下
如 (0.1, -0.05, 2.5)米 DET->>TRK: Armor.pose (camera_optical_frame) TRK->>TF2: 查找变换
camera_optical_frame → odom TF2->>TRK: 返回旋转R + 平移T TRK->>TRK: 变换到 odom 坐标系
如 (1.2, 0.3, 0.5)米 Note over TRK,EKF: 在 odom 惯性系下
同一个目标的连续帧位置
可以直接做差分/滤波 TRK->>EKF: 更新观测值 (odom系) EKF->>EKF: 状态更新
得到 x, y, z, vx, vy, vz, yaw, v_yaw EKF->>TRK: 输出完整运动状态 Note over TRK: 外推预测后发送给 serial_driver

核心思想

检测器只能告诉你"目标在相机前方2.5米"——但云台在转动,下帧相机朝向就变了,这个2.5米的方向也变了。如果不变换到 odom 惯性系,连续帧的观测值根本对不上,EKF无法正确跟踪。


7. 代码走读:串口协议

7.1 packet.hpp —— 协议结构体定义

串口通信的核心是 packet.hpp,它定义了视觉端和电控端之间 所有数据包的二进制格式

rm_serial_driver/include/rm_serial_driver/packet.hpp
namespace rm_serial_driver
{
struct Header
{
  uint8_t header = 0xA5;
  uint16_t data_length = 15;
  uint8_t seq = 0;
  uint8_t crc8 = 0;
  uint16_t cmd_id;
} __attribute__((packed));

逐行解读

  • uint8_t header = 0xA5:帧头标志,固定为 0xA5。接收方通过扫描连续字节流找到 0xA5 来同步帧边界
  • uint16_t data_length = 15:后续数据的长度(不包含 Header 本身)。默认 15 字节
  • uint8_t seq = 0:序列号,每发一包递增。接收方可以检测是否丢包(序号不连续就说明丢了)
  • uint8_t crc8 = 0:Header 自身的 CRC8 校验。先校验 Header,如果 Header 就错了,整个包直接丢弃,省去解析数据区的开销
  • uint16_t cmd_id:命令 ID,标识这个包是什么类型的数据(IMU、裁判系统、底盘数据等)
  • __attribute__((packed)):GCC 编译器指令,禁止结构体内存对齐。没有它,编译器会在字段间插入 padding 字节,导致结构体大小与线上协议不匹配,数据解析出错

7.2 IMUPacket —— 云台姿态数据

struct IMUPacket
{
  Header header;
  float pitch;
  float roll;
  float yaw;
  uint8_t roboid;
  uint8_t id;
  uint8_t timeseries;
  uint16_t crc16 = 0;
} __attribute__((packed));

逐行解读

  • Header header:通用帧头,包含 0xA5 标志和命令 ID
  • float pitch:云台俯仰角(度),正值向上看。这是瞄准计算的核心输入之一
  • float roll:云台横滚角(度),正常情况下接近 0(车身水平时)
  • float yaw:云台偏航角(度),相对于 odom 系的旋转角度。serial_driver 用它来广播 odom → gimbal_link 的 TF 变换
  • uint8_t roboid:机器人 ID,标识当前是哪台车
  • uint8_t id:装甲板 ID 或模式标识
  • uint8_t timeseries:测试时间戳,用于调试延迟测量
  • uint16_t crc16:对整个包(除 CRC 字段本身)计算的 CRC16 校验

IMUPacket 的作用链路

STM32 通过串口发送 IMUPacket → serial_driver 解析出 pitch/roll/yaw → 转换为 odom → gimbal_link 的 TF 变换 → tracker 查询这个 TF 将装甲板位置变换到 odom 系。这就是为什么 tracker 必须等 serial_driver 启动——它依赖 IMU 数据来建立坐标变换。

7.3 裁判系统数据包

2027 版新增了裁判系统数据的转发,serial_driver 直接解析裁判系统报文并发布到 ROS2 话题:

struct GameStatusPacket
{
  Header header;
  uint8_t game_info;
  uint16_t stage_remain_time;
  uint64_t sync_timestamp;
  uint16_t crc16 = 0;
} __attribute__((packed));

逐行解读

  • uint8_t game_info:比赛信息的打包字段。低 4 位存储比赛类型(1v1/3v3/5v5 等),高 4 位存储比赛阶段(未开始/准备/倒计时/进行中/结算)
  • uint16_t stage_remain_time:当前阶段剩余时间(秒)。决策节点用它来判断是否需要紧急动作
  • uint64_t sync_timestamp:裁判系统同步时间戳,用于对齐多个数据源的时间
struct GameRobotStatusPacket
{
  Header header;
  uint8_t robot_id;
  uint8_t robot_level;
  uint16_t remain_hp;
  uint16_t max_hp;
  uint16_t shooter_cooling_rate;
  uint16_t shooter_heat_limit;
  uint16_t chassis_power_limit;
  uint8_t mains_power_state;
  uint16_t crc16 = 0;
} __attribute__((packed));

逐行解读

  • uint8_t robot_id:机器人 ID(1-8),用于区分不同机器人
  • uint8_t robot_level:机器人等级,影响血量和功率上限
  • uint16_t remain_hp:当前剩余血量。决策节点根据血量判断是否撤退(hp_threshold 参数)
  • uint16_t max_hp:最大血量,与等级相关
  • uint16_t shooter_cooling_rate:枪口冷却速率(热量/秒),影响射击频率限制
  • uint16_t shooter_heat_limit:枪口热量上限,超过此值无法射击
  • uint16_t chassis_power_limit:底盘功率上限(瓦),影响移动速度
  • uint8_t mains_power_state:电源状态位——bit0/1/2 分别表示云台/底盘/发射机构是否供电

这些数据怎么用?

决策节点订阅 /serial/game_status/serial/game_robot_status,综合血量、比赛阶段、功率限制等信息来决定机器人的行为策略。例如:血量低时主动撤退,比赛快结束时切换到激进策略等。

7.4 ReceivePacket —— 从STM32接收

struct ReceivePacket
{
  uint8_t header = 0xA5;
  uint16_t size;
  uint8_t id;
  uint8_t crc8;
  uint16_t cmd_id;
  float pitch;
  float roll;
  float yaw;
  uint16_t checksum = 0;
} __attribute__((packed));

逐行解读

  • uint8_t header = 0xA5:帧头 0xA5,与 Header 结构中的相同
  • uint16_t size:数据区大小
  • uint8_t id:包 ID
  • uint8_t crc8:Header 部分的 CRC8 校验
  • uint16_t cmd_id:命令 ID,标识这个包的业务类型
  • float pitch/roll/yaw:云台姿态三轴角度(度)。serial_driver 收到后转换为四元数,广播 odom → gimbal_link 的 TF 变换
  • uint16_t checksum:整个包的 CRC16 校验,接收方验证通过后才使用数据
graph LR A["Header
0xA5 1B"] --> B["size
2B"] --> C["id
1B"] --> D["crc8
1B"] --> E["cmd_id
2B"] --> F["pitch
4B"] --> G["roll
4B"] --> H["yaw
4B"] --> I["CRC16
2B"] style A fill:#9C27B0,color:#fff style F fill:#4CAF50,color:#fff style G fill:#FF9800,color:#fff style H fill:#F44336,color:#fff style I fill:#607D8B,color:#fff

7.5 SendPacket —— 发送给STM32

这是视觉端每帧发给STM32的数据包:

struct SendPacket
{
  Header header;
  uint8_t id = 0;
  uint8_t robo_id = 0;
  float pitch;
  float yaw;
  uint8_t accuracy = 50;
  uint8_t shoot;
  uint16_t checksum = 0;
} __attribute__((packed));

逐行解读

  • Header header:通用帧头(0xA5 + cmd_id 等)
  • uint8_t id = 0:目标装甲板 ID,0 表示无目标
  • uint8_t robo_id = 0:目标机器人 ID
  • float pitch解算后的目标俯仰角(弧度),即"云台应该转到的 Pitch 角度"。注意 2027 版发送的是 角度 而非原始位置——弹道解算在视觉端完成
  • float yaw解算后的目标偏航角(弧度),即"云台应该转到的 Yaw 角度"
  • uint8_t accuracy = 50:命中概率估计值(0-100),默认 50。电控端可以据此决定是否开火
  • uint8_t shoot:射击指令,0=不射击,1=射击。视觉端根据命中概率和目标状态决定是否开火
  • uint16_t checksum:CRC16 校验

2027版与rm_vision的区别

对比 rm_vision 的 SendPacket(发送 x, y, z, yaw, vx, vy, vz, v_yaw, r1, r2, dz),2027版只发送 pitch + yaw + shoot。这说明 2027 版采用了 混合架构——弹道解算在视觉端完成,电控端只需要做角度伺服。这是更高阶的设计,但视觉端代码更复杂。

7.6 辅助数据包

除了核心的自瞄数据包,还定义了底盘和导航相关的包:

struct classisPacket
{
  Header header;
  float vx;
  float vy;
  float vz;
  uint8_t enable;
  uint16_t crc16 = 0;
} __attribute__((packed));

逐行解读

  • float vx, vy, vz:底盘速度指令(米/秒),用于导航模块控制底盘移动
  • uint8_t enable:底盘运动使能标志,0=锁死,1=允许运动。安全开关
struct NavigationPacket
{
  Header header;
  float vx;
  float vy;
  float wz;
  uint16_t crc16;
} __attribute__((packed, aligned(1)));

逐行解读

  • float vx, vy:平面线速度指令
  • float wz:绕 Z 轴的角速度(弧度/秒),用于转向控制
  • aligned(1):与 packed 配合,确保 1 字节对齐,消除所有 padding

7.7 序列化工具函数

最后是将结构体和字节流互相转换的模板函数:

template <typename T>
inline T fromVector(const std::vector<uint8_t> & data)
{
  T packet;
  std::copy(data.begin(), data.end(), reinterpret_cast<uint8_t *>(&packet));
  return packet;
}

inline std::vector<uint8_t> toVector(const SendPacket & data)
{
  std::vector<uint8_t> packet(sizeof(SendPacket));
  std::copy(
    reinterpret_cast<const uint8_t *>(&data),
    reinterpret_cast<const uint8_t *>(&data) + sizeof(SendPacket), packet.begin());
  return packet;
}

逐行解读

fromVector(反序列化:字节流 → 结构体): - template <typename T>:模板函数,可以将字节流转换为任意包类型(IMUPacket、ReceivePacket 等) - T packet:在栈上创建目标类型的实例(字段有默认值) - reinterpret_cast<uint8_t *>(&packet):将结构体指针强制转换为字节指针——因为 __attribute__((packed)) 保证了内存布局与线上协议完全一致,所以直接拷贝字节就是正确的解析 - std::copy:将 vector 中的字节逐一拷贝到结构体的内存空间

toVector(序列化:结构体 → 字节流): - sizeof(SendPacket):获取结构体的字节大小(packed 后没有 padding,大小精确) - 创建一个正好那么大的 vector<uint8_t> - 将结构体的原始字节拷贝到 vector 中,可以直接通过串口发送

为什么不用 protobuf 或 JSON?

在嵌入式串口通信中,二进制协议是最优选择: - 体积小:SendPacket 仅约 20+ 字节,JSON 表示同样的信息需要 100+ 字节 - 解析快:直接内存映射,零解析开销 - 确定性:packed 结构体的字节布局是确定的,不会因为解析器版本不同而行为不一致 - STM32 友好:MCU 资源有限,不需要 JSON/protobuf 库

7.8 CRC校验

串口通信使用 CRC8 + CRC16 双层校验

rm_serial_driver/include/rm_serial_driver/crc.hpp
namespace crc8
{
uint8_t Verify_CRC8_Check_Sum(const uint8_t * pchMessage, uint32_t dwLength);
void Append_CRC8_Check_Sum(uint8_t * pchMessage, uint32_t dwLength);
}

namespace crc16
{
uint32_t Verify_CRC16_Check_Sum(const uint8_t * pchMessage, uint32_t dwLength);
void Append_CRC16_Check_Sum(uint8_t * pchMessage, uint32_t dwLength);
}

逐行解读

  • crc8 命名空间:用于 Header 校验。Verify 函数返回校验结果(True/False),Append 函数在发送前自动计算并填充 crc8 字段
  • crc16 命名空间:用于整个数据包的校验。流程同上
  • 为什么用两层校验:先校验 Header 的 CRC8,如果 Header 就错了,直接丢弃整个包,不用解析数据区。只有 Header 正确了,才继续校验数据区的 CRC16。这是短路优化——在高速串口通信中,每一微秒都很重要

串口干扰是真实问题

在 RoboMaster 比赛中,电磁环境非常恶劣:电机驱动器的 PWM 信号、超级电容的充放电、其他队伍的通信设备都会产生电磁干扰。串口线上的随机比特翻转是家常便饭。没有 CRC 校验的串口通信是不可靠的


8. 与其它视觉方案的架构对比

在 RoboMaster 社区中,有多种不同的自瞄系统架构设计。理解它们的异同有助于你把握 设计决策背后的权衡

8.1 三种架构的对比总览

graph TB subgraph rm_vision direction LR RM_V["🔍 检测 → 🎯 跟踪 → 📡 串口发送运动状态
⚡ 电控端做弹道解算"] end subgraph sp_vision_25 direction LR SP_V["🔍 检测 → 🎯 跟踪 → 🔫 弹道解算 → 📡 串口发送角度
🖥️ 视觉端做弹道解算"] end subgraph RM_Vision_2027 direction LR RM27_V["🔍 检测 → 🎯 跟踪 → 🔫 弹道解算 → 📡 串口发送混合包
🖥️ 视觉端解算 + 状态同步"] end style rm_vision fill:#2196F3,color:#fff style sp_vision_25 fill:#4CAF50,color:#fff style RM_Vision_2027 fill:#FF9800,color:#fff

8.2 rm_vision:弹道在电控端

flowchart LR subgraph 视觉端 DET1["detector"] --> TRK1["tracker"] TRK1 --> SERIAL1["serial_driver"] end subgraph 电控端 BALLISTIC1["弹道解算"] AIM1["云台控制"] end SERIAL1 -- "SendPacket
x, y, z, yaw
vx, vy, vz, v_yaw
r1, r2, dz" --> BALLISTIC1 BALLISTIC1 --> AIM1

设计思路:

  • 视觉端只负责 "告诉我目标的状态"
  • 电控端负责 "算怎么打到它"
  • 职责分离清晰,视觉端不需要知道弹丸参数

优点:

  • 视觉端代码更简单,专注于检测和跟踪
  • 换弹丸/调弹道参数只需改电控端
  • 电控端可以实时微调弹道(比如根据气压温度修正)

缺点:

  • 串口传输字段多(52字节),对带宽有要求
  • 电控端STM32需要运行弹道解算,增加MCU负担
  • 弹道解算精度受STM32计算能力限制

8.3 sp_vision_25:弹道在视觉端

flowchart LR subgraph 视觉端 DET2["detector"] --> TRK2["tracker"] TRK2 --> BALLISTIC2["弹道解算 + 射击决策"] BALLISTIC2 --> SERIAL2["serial_driver"] end subgraph 电控端 AIM2["云台角度伺服"] end SERIAL2 -- "SendPacket
yaw, pitch, shoot" --> AIM2

设计思路:

  • 视觉端完成从检测到弹道解算的全部工作
  • 只告诉电控端"转到这个角度,然后开火"
  • 电控端变成纯执行机构

优点:

  • 串口协议简单(只有几个字段)
  • 电控端只需要做角度伺服,代码简单
  • 视觉端可以用更强大的CPU做复杂弹道计算(考虑空气阻力、陀螺仪修正等)

缺点:

  • 视觉端代码复杂度高,需要集成弹道模型
  • 换弹丸/调弹道参数需要改视觉端代码
  • 视觉端需要精确的弹丸初速等参数(需要从电控同步过来)

8.4 RM_Vision_2027:混合架构

flowchart LR subgraph 视觉端 DET3["detector"] --> TRK3["tracker"] TRK3 --> BALLISTIC3["弹道解算"] BALLISTIC3 --> SERIAL3["serial_driver"] end subgraph 电控端 AIM3["云台角度伺服"] end SERIAL3 -- "SendPacket
pitch + yaw + shoot" --> AIM3

设计思路:

  • 视觉端同时计算运动状态和弹道解算
  • 串口包中只发送 解算后的角度和射击指令
  • 电控端只做角度伺服

从代码看设计:

2027版 SendPacket 字段分析
struct SendPacket
{
  Header header;
  uint8_t id = 0;          // 目标ID
  uint8_t robo_id = 0;     // 机器人ID
  float pitch;             // 瞄准Pitch角 ← 弹道解算结果
  float yaw;               // 瞄准Yaw角   ← 弹道解算结果
  uint8_t accuracy = 50;   // 命中概率估计
  uint8_t shoot;           // 射击指令     ← 射击决策结果
  uint16_t checksum = 0;
};

对比 rm_vision 的 SendPacket(x, y, z, yaw, vx, vy, vz, v_yaw, r1, r2, dz),2027版把原始运动状态换成了 解算后的角度,并增加了 accuracyshoot 字段。

优点:

  • 串口包小,带宽消耗低
  • 电控端极简,只需做角度伺服
  • 视觉端可以用 CPU 做精确的空气阻力弹道模型

缺点:

  • 视觉端代码最复杂(检测 + 跟踪 + 弹道 + 射击决策)
  • 弹道参数(弹丸初速等)需要从电控端同步过来

8.5 三种方案对比表

特性 rm_vision sp_vision_25 RM_Vision_2027
弹道解算位置 电控端 视觉端 视觉端
视觉串口包大小 ~52字节 ~13字节 ~20字节
串口字段 运动状态全量 yaw, pitch, shoot pitch, yaw, accuracy, shoot
电控端复杂度 高(需要弹道模型) 低(纯伺服) 低(纯伺服)
视觉端复杂度 低(只跟踪) 高(跟踪+弹道) 最高(跟踪+弹道+决策+裁判系统)
弹道参数调整 改电控固件 改视觉配置 改视觉配置
适合场景 入门/教学 竞赛优化 顶级队伍

哪种方案最好?

没有绝对最好的方案,取决于团队能力和比赛需求:

  • rm_vision:最适合 学习和入门。代码结构清晰,职责分明,是理解自瞄系统的最佳起点
  • sp_vision_25:适合 有经验的队伍。视觉端控制一切,可以做更精细的弹道优化
  • RM_Vision_2027:适合 顶级队伍。信息最完整,但对开发和调试能力要求最高

本手册以 rm_vision 的架构思路为基础讲解检测和跟踪算法,同时通过 RM_Vision_2027 的真实代码展示实际工程实践。


本章小结

graph TB ROOT["本章知识体系"] ROOT --> DF["数据流"] ROOT --> PKG["5个ROS2包"] ROOT --> TF["TF2坐标系"] ROOT --> PROTO["通信协议"] ROOT --> ARCH["架构选择"] DF --> DF1["相机 → 检测器"] DF --> DF2["检测器 → 跟踪器"] DF --> DF3["跟踪器 → 串口驱动"] DF --> DF4["串口驱动 ↔ STM32"] PKG --> PKG1["auto_aim_interfaces 定义消息"] PKG --> PKG2["armor_detector 检测装甲板"] PKG --> PKG3["armor_tracker EKF跟踪"] PKG --> PKG4["rm_serial_driver 串口通信"] PKG --> PKG5["rm_vision_bringup 启动配置"] TF --> TF1["odom 世界系"] TF --> TF2["gimbal_link 云台系"] TF --> TF3["camera_link 相机物理系"] TF --> TF4["camera_optical_frame 光学系"] PROTO --> PROTO1["ReceivePacket: 云台姿态"] PROTO --> PROTO2["SendPacket: 瞄准角度"] PROTO --> PROTO3["CRC8 + CRC16 双层校验"] ARCH --> ARCH1["rm_vision 弹道在电控"] ARCH --> ARCH2["sp_vision 弹道在视觉"] ARCH --> ARCH3["2027 混合架构"] style ROOT fill:#673AB7,color:#fff style DF fill:#2196F3,color:#fff style PKG fill:#4CAF50,color:#fff style TF fill:#FF9800,color:#fff style PROTO fill:#F44336,color:#fff style ARCH fill:#00BCD4,color:#fff

关键收获:

  1. 自瞄系统是一条 数据流水线:相机 → 检测 → 跟踪 → 串口 → 电控
  2. Component零拷贝 是最重要的性能优化:相机和检测器同进程共享图像
  3. TF2坐标变换 让检测结果从相机系变换到世界系,是跟踪的前提
  4. 消息定义就是合同:Armor.msg / Armors.msg / Target.msg 定义了节点之间的数据格式
  5. 串口协议使用 packed 结构体:直接内存映射,零解析开销,双层 CRC 校验保证可靠性
  6. Launch文件的延迟启动 不是bug,是精心设计的启动顺序保证
  7. 2027版采用混合架构:视觉端完成弹道解算,串口包只发角度和射击指令

下一步

理解了系统架构的全局视图后,建议按以下顺序深入每个模块:

flowchart LR NOW["✅ 第6章
系统架构"] --> NEXT1["第7章
装甲板检测"] NEXT1 --> NEXT2["第8章
目标跟踪"] NEXT2 --> NEXT3["第9章
PnP解算"] NEXT3 --> NEXT4["第10章
弹道与瞄准"]

每一章都会聚焦一个包,深入到代码级别讲解算法实现。