第6章 系统架构总览¶
你将在本章学到什么¶
本章是整个自瞄系统的 鸟瞰图。你将理解:
- 数据如何从相机流向电控
- 5个ROS2包各自负责什么
- 节点之间通过哪些Topic通信
- 为什么需要TF2坐标变换
- Launch文件如何把一切串起来
- 串口协议的每一个字节是什么意思
读本章之前的准备
建议先完成 第4章 ROS2核心概念 的学习,至少理解 节点(Node)、话题(Topic)、组件(Component) 和 Launch文件 的基本概念。
1. 系统总览¶
1.1 一句话版本¶
自瞄系统做的事: 相机拍照 → 检测装甲板 → 跟踪目标 → 通过串口把目标信息发给STM32 → 电控端解算弹道并控制云台射击。
1.2 数据流全景图¶
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 —— 单块装甲板¶
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 —— 装甲板列表¶
逐行解读
std_msgs/Header header:标准消息头,包含时间戳stamp(图像采集时间)和坐标系 IDframe_id(通常是"camera_optical_frame")。时间戳至关重要——跟踪器需要用它来计算帧间时间差,用于 EKF 的状态预测。Armor[] armors:检测到的所有装甲板的数组。一帧图像中可能检测到 0 到多个装甲板。当机器人背对相机时,可能检测到 2 块装甲板(前后各一)。当阵亡时,可能检测到 0 块。
3.3 Target.msg —— 跟踪目标¶
这是 tracker → serial_driver 之间传递的核心消息:
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_1和radius_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 文件共享,提供了统一的参数加载和节点定义:
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
- xyz 和 rpy 参数表示相机相对于云台的安装偏移——这会在 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 —— 主启动文件¶
这是整个自瞄系统的入口文件,负责按正确的顺序启动所有节点:
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.yamlon_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 启动顺序时序图¶
(相机+检测器) 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的发布/订阅关系:
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 坐标系层次结构¶
世界/惯性坐标系
(固定不动)"] 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轴方向不同:
| 坐标系 | 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变换的来源¶
描述机器人模型"] 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 为什么需要坐标变换?¶
这是一道贯穿整个自瞄系统的关键题:
在 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,它定义了视觉端和电控端之间 所有数据包的二进制格式。
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 标志和命令 IDfloat 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:包 IDuint8_t crc8:Header 部分的 CRC8 校验uint16_t cmd_id:命令 ID,标识这个包的业务类型float pitch/roll/yaw:云台姿态三轴角度(度)。serial_driver 收到后转换为四元数,广播odom → gimbal_link的 TF 变换uint16_t checksum:整个包的 CRC16 校验,接收方验证通过后才使用数据
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:目标机器人 IDfloat 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 双层校验:
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 三种架构的对比总览¶
⚡ 电控端做弹道解算"] 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:弹道在电控端¶
x, y, z, yaw
vx, vy, vz, v_yaw
r1, r2, dz" --> BALLISTIC1 BALLISTIC1 --> AIM1
设计思路:
- 视觉端只负责 "告诉我目标的状态"
- 电控端负责 "算怎么打到它"
- 职责分离清晰,视觉端不需要知道弹丸参数
优点:
- 视觉端代码更简单,专注于检测和跟踪
- 换弹丸/调弹道参数只需改电控端
- 电控端可以实时微调弹道(比如根据气压温度修正)
缺点:
- 串口传输字段多(52字节),对带宽有要求
- 电控端STM32需要运行弹道解算,增加MCU负担
- 弹道解算精度受STM32计算能力限制
8.3 sp_vision_25:弹道在视觉端¶
yaw, pitch, shoot" --> AIM2
设计思路:
- 视觉端完成从检测到弹道解算的全部工作
- 只告诉电控端"转到这个角度,然后开火"
- 电控端变成纯执行机构
优点:
- 串口协议简单(只有几个字段)
- 电控端只需要做角度伺服,代码简单
- 视觉端可以用更强大的CPU做复杂弹道计算(考虑空气阻力、陀螺仪修正等)
缺点:
- 视觉端代码复杂度高,需要集成弹道模型
- 换弹丸/调弹道参数需要改视觉端代码
- 视觉端需要精确的弹丸初速等参数(需要从电控同步过来)
8.4 RM_Vision_2027:混合架构¶
pitch + yaw + shoot" --> AIM3
设计思路:
- 视觉端同时计算运动状态和弹道解算
- 串口包中只发送 解算后的角度和射击指令
- 电控端只做角度伺服
从代码看设计:
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版把原始运动状态换成了 解算后的角度,并增加了 accuracy 和 shoot 字段。
优点:
- 串口包小,带宽消耗低
- 电控端极简,只需做角度伺服
- 视觉端可以用 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 的真实代码展示实际工程实践。
本章小结¶
关键收获:
- 自瞄系统是一条 数据流水线:相机 → 检测 → 跟踪 → 串口 → 电控
- Component零拷贝 是最重要的性能优化:相机和检测器同进程共享图像
- TF2坐标变换 让检测结果从相机系变换到世界系,是跟踪的前提
- 消息定义就是合同:Armor.msg / Armors.msg / Target.msg 定义了节点之间的数据格式
- 串口协议使用 packed 结构体:直接内存映射,零解析开销,双层 CRC 校验保证可靠性
- Launch文件的延迟启动 不是bug,是精心设计的启动顺序保证
- 2027版采用混合架构:视觉端完成弹道解算,串口包只发角度和射击指令
下一步¶
理解了系统架构的全局视图后,建议按以下顺序深入每个模块:
系统架构"] --> NEXT1["第7章
装甲板检测"] NEXT1 --> NEXT2["第8章
目标跟踪"] NEXT2 --> NEXT3["第9章
PnP解算"] NEXT3 --> NEXT4["第10章
弹道与瞄准"]
每一章都会聚焦一个包,深入到代码级别讲解算法实现。