4. 数据采集软件框架:ROS2基础、节点与话题通信、数据采集节点编写、数据录制与回放(rosbag)

做机器人数据采集,绕不开ROS2。说实话,我见过不少团队在自研通信协议上浪费了大量时间,最后发现ROS2早就把坑填平了。这一章,我们就从实战角度把ROS2的数据采集框架拆开来看。

4.1 ROS2核心概念:节点、话题、消息

ROS2本质上是一个分布式通信框架。它的核心就三样东西:节点、话题、消息。

  • 节点:一个可执行程序,负责完成某一项具体任务。比如一个节点专门读激光雷达,另一个节点专门做定位。
  • 话题:节点之间通信的“总线”。一个节点往话题上发数据,另一个节点从话题上收数据。
  • 消息:话题上传输的数据格式。可以是简单的int32,也可以是复杂的点云数据。

我刚开始用ROS2时犯过一个低级错误:以为节点越多越好,结果一个机器人上跑了40多个节点,系统调度开销大得离谱。后来才明白,节点是逻辑单元,不是性能单元。同一个传感器驱动,完全可以用一个节点搞定。

关键认知:ROS2的节点是松耦合的。一个节点挂了,其他节点不受影响。这对数据采集来说太重要了——你不会因为一个传感器驱动崩溃而丢失所有数据。

4.2 话题通信:发布-订阅模型

ROS2的通信模型是典型的发布-订阅模式。发布者往话题上扔数据,订阅者从话题上取数据。两者互不知道对方的存在。

为什么会这样设计?你想想看,如果发布者必须知道订阅者的地址,那系统就变成点对点了。一旦订阅者数量变化,发布者代码就得改。ROS2这种设计,让系统扩展变得极其简单。

我在项目中遇到过一个问题:多个订阅者同时订阅同一个话题,数据会不会重复拷贝?答案是会的。ROS2默认会给每个订阅者单独拷贝一份数据。如果你的消息体很大(比如一张2MB的图像),内存消耗会直线上升。解决办法是用零拷贝传输,或者用rclcpp::Publisher的best_effort策略。

通信方式 适用场景 可靠性 性能
发布-订阅 传感器数据流 可配置(Reliable/Best Effort) 高
服务 请求-响应 高 中
动作 长时间任务 高 中

实战建议:数据采集场景下,传感器话题建议用Best Effort策略。因为丢一帧数据没关系,但延迟会导致时间戳错乱。我曾经因为用了Reliable策略,导致激光雷达数据积压,最后时间戳全乱了。

4.3 数据采集节点编写

写一个数据采集节点,其实就三步:初始化节点、创建发布者、循环发布数据。下面是一个完整的C++示例,采集IMU数据。

#include <rclcpp/rclcpp.hpp>
#include <sensor_msgs/msg/imu.hpp>

class ImuCollector : public rclcpp::Node
{
public:
    ImuCollector() : Node("imu_collector")
    {
        publisher_ = this->create_publisher<sensor_msgs::msg::Imu>("imu/data", 10);
        timer_ = this->create_wall_timer(
            std::chrono::milliseconds(10),
            std::bind(&ImuCollector::timer_callback, this));
    }

private:
    void timer_callback()
    {
        auto msg = sensor_msgs::msg::Imu();
        msg.header.stamp = this->now();
        msg.header.frame_id = "imu_link";
        // 这里填充实际的IMU数据
        msg.linear_acceleration.x = 0.1;
        msg.angular_velocity.z = 0.05;
        publisher_->publish(msg);
    }

    rclcpp::Publisher<sensor_msgs::msg::Imu>::SharedPtr publisher_;
    rclcpp::TimerBase::SharedPtr timer_;
};

int main(int argc, char *argv[])
{
    rclcpp::init(argc, argv);
    rclcpp::spin(std::make_shared<ImuCollector>());
    rclcpp::shutdown();
    return 0;
}

这段代码里有个细节:msg.header.stamp = this->now();。时间戳必须用ROS2的系统时钟,不能用std::chrono。因为ROS2的时间戳是全局统一的,后续做数据融合时全靠它对齐。

踩坑提醒:我曾经在采集数据时,直接用std::chrono::system_clock::now()打时间戳。结果rosbag回放时,时间戳和系统时间对不上,数据全部作废。记住:ROS2消息的时间戳必须用this->now()。

4.4 数据录制与回放:rosbag

rosbag是ROS2的数据录制工具。说白了,它就是把话题上的数据流“录”下来,存成一个文件。需要的时候再“回放”出来。

基本用法很简单:

# 录制所有话题
ros2 bag record -a

# 录制指定话题
ros2 bag record /imu/data /camera/image_raw

# 回放数据
ros2 bag play my_bag_file

# 查看bag信息
ros2 bag info my_bag_file

但实际项目中,我很少用-a录制所有话题。为什么?因为有些话题频率极高(比如IMU 1000Hz),有些话题数据量极大(比如相机30fps)。全录下来,一个bag文件轻松上几十GB。

我一般会先跑一次ros2 topic list,看看有哪些话题,然后只录需要的:

ros2 bag record \
  /imu/data \
  /camera/image_raw \
  /lidar/points \
  /odom \
  -o experiment_001

-o参数指定输出文件名,方便管理。

4.5 知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以看到,从传感器硬件到数据文件,中间经过了节点、话题、rosbag三个关键环节。

ROS2数据采集框架 传感器硬件 传感器硬件 传感器硬件 采集节点1 采集节点2 采集节点3 话题(Topic) rosbag 数据文件 核心流程 传感器 → 节点 节点 → 话题 话题 → rosbag

4.6 数据采集的工程实践

光会写节点和录bag还不够。实际项目中,数据采集的坑比你想的多。我总结了几条经验:

  1. 先跑通再录:别一上来就录数据。先开一个终端订阅话题,确认数据格式、频率、内容都对,再开始录制。
  2. 命名规范:bag文件名要包含日期、实验编号、传感器组合。比如20241015_exp01_imu_cam_lidar。不然一个月后你根本不知道这个bag里是什么。
  3. 数据校验:录完数据后,立刻用ros2 bag info检查话题数量、消息总数、时长。我见过有人录了2小时,结果发现话题名配错了,数据全白录。
  4. 磁盘空间:录数据前先df -h看看磁盘剩余空间。一个100Hz的激光雷达加30fps的相机,一小时轻松吃掉50GB。

我的习惯:每次录数据前,我会写一个简单的启动脚本,把节点启动、话题检查、bag录制串起来。这样一键启动,不会漏步骤。脚本里还会加一个trap,保证Ctrl+C时能正常关闭bag文件,不会损坏。

4.7 回放数据的注意事项

rosbag回放时,默认会按原始时间戳发布数据。也就是说,如果你录了10分钟的数据,回放也需要10分钟。但你可以用--rate参数加速:

# 2倍速回放
ros2 bag play my_bag --rate 2.0

# 循环回放
ros2 bag play my_bag --loop

# 只回放指定话题
ros2 bag play my_bag --topics /imu/data /camera/image_raw

回放时有个坑:如果你在回放的同时启动其他节点,这些节点会收到回放的数据。如果节点里有写文件的操作,可能会把回放数据当成真实数据存下来。所以,回放数据时,最好在一个干净的ROS2环境里操作,或者用ROS_DOMAIN_ID隔离。

我曾经踩过的坑:有一次我在调试定位算法,用rosbag回放数据。结果定位节点把回放数据当成了真实传感器数据,直接输出了错误的位姿,还写入了日志文件。后来排查了半天才发现是回放数据污染了。从那以后,我回放数据时都会单独开一个ROS_DOMAIN_ID,彻底隔离。