ROS2 集成方案

你的 Ubuntu 22.04 对应 ROS2 Humble——这是生态支持最好的组合, 也是车企量产主流。本节给出三条路线的取舍,并推荐一条能彻底绕开版本地狱的做法。

难度 ★★★☆☆ 时间 约 2.5 小时 前置 1.2 定位 可先读不做
▸ 这节课的使用方式

按 M1 总览的决策,ROS2 的实操价值在 M6 部署阶段, 现在做算法阶段用纯 Python 直连 CARLA 更高效。

所以本节推荐:现在通读一遍,理解方案设计和版本关系; 实际动手可以推迟到 M6。但如果你想早点把 rviz2 可视化用起来(调试确实方便), 现在做也完全可以——本节给的方案不会拖慢你。

先说结论:三条路线怎么选

方案做法优点风险
① 官方 ros-bridge 装 carla-ros-bridge 功能全(TF、交通灯、地图全都有) 主线针对 Foxy,Humble 要用社区分支,版本随 CARLA 升级易失效
② CARLA 原生 ROS2 启动加 --ros2 无需额外组件,官方维护 较新功能,话题覆盖不全(TF/地图等仍在完善)
③ 自己写轻量桥接 用 rclpy 发你需要的话题 版本无关、完全可控、学到真东西 需要自己写(但只有 200 行)
▸ 本课程推荐方案 ③,理由有三

① 彻底绕开版本地狱。你自己写的桥只依赖 rclpy 和 CARLA Python API, 两边都是稳定接口。CARLA 升到 0.9.16、ROS2 升到 Jazzy,你的代码基本不用改。

② 你只需要少数几个话题。官方 bridge 发几十个话题, 而你的项目实际只用图像、点云、里程计、TF 这四类。为用不到的功能承担维护成本不划算。

③ 面试价值更高。"我用了官方 bridge" 和 "我自己实现了 CARLA 到 ROS2 的数据桥接,处理了时间同步与 TF 树构建"—— 后者才体现工程能力。而这正是你目标岗位要考察的。

第一步:安装 ROS2 Humble

Ubuntu 22.04 是 Humble 的官方目标平台,直接 apt 装即可,不需要 Docker。

bash
# ---------- 1. 设置字符集 ----------
sudo apt update && sudo apt install -y locales
sudo locale-gen en_US en_US.UTF-8
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
export LANG=en_US.UTF-8

# ---------- 2. 添加 ROS2 apt 源 ----------
sudo apt install -y software-properties-common curl
sudo add-apt-repository universe -y

sudo curl -sSL \
  https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
  -o /usr/share/keyrings/ros-archive-keyring.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" \
  | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null

# ---------- 3. 安装 Humble ----------
sudo apt update
sudo apt install -y ros-humble-desktop   # 含 rviz2,约 2GB
# 若只要核心不要 GUI: sudo apt install ros-humble-ros-base

# 开发工具
sudo apt install -y python3-colcon-common-extensions python3-rosdep

# ---------- 4. 验证 ----------
source /opt/ros/humble/setup.bash
ros2 --version
printenv ROS_DISTRO      # 应输出 humble

# 跑个官方示例确认通信正常(开两个终端)
# 终端A: ros2 run demo_nodes_cpp talker
# 终端B: ros2 run demo_nodes_py listener
▸ 关键坑:conda 与 ROS2 的 Python 冲突

这是 22.04 上最常见的问题。ROS2 Humble 用系统 Python 3.10,你的 conda 环境也是 3.10—— 同时 source 两边会导致 rclpy 找不到、或 numpy 版本打架。

本课程的处理原则:不要在同一个终端里同时激活两者。

bash~/.bashrc 建议配置
# 不要把这两行都自动执行,改成手动切换的函数

# 用 ROS2 时执行这个
use_ros() {
    conda deactivate 2>/dev/null
    source /opt/ros/humble/setup.bash
    echo "→ ROS2 Humble 环境(系统 Python)"
}

# 用训练环境时执行这个
use_ad() {
    conda activate ad
    echo "→ 训练环境 ad(conda Python)"
}

# 桥接脚本要同时用 carla 和 rclpy —— 在 ROS2 环境里单独装 carla
#   use_ros
#   pip install --user carla==0.9.15

为什么这样分:训练(PyTorch)和 ROS2 通信本来就是两个阶段的事, 物理隔离比费劲调和依赖更省心。M6 做部署时,模型会先导出成 TensorRT/ONNX, ROS2 节点只负责加载推理引擎,不需要 PyTorch。

第二步:理解桥接要做什么

CARLA 服务端 RPC 端口 2000 相机 · 雷达 车辆状态 Python API 桥接节点(你写的) ① 同步取数据 ② 格式转换 ③ 坐标系转换 ④ 时间戳对齐 rclpy · 约 200 行 DDS /carla/camera/image /carla/lidar/points /carla/odom /tf 坐标树 /carla/cmd 控制 下游消费者 rviz2 可视化 感知节点 规划节点 M6 会用到 桥接节点的本质:把 CARLA 的数据「翻译」成 ROS2 生态的通用语言 翻译好了,整个 ROS2 工具链(rviz2、rosbag、tf2)就都能用了
图 · 桥接节点的位置与职责。它是 CARLA 与 ROS2 生态之间的翻译层。
▸ 桥接的四个技术难点

① 时间戳:CARLA 的仿真时间与 ROS2 时钟不同步,需要统一(用 use_sim_time)。

② 坐标系:CARLA 是左手系(1.4 节讲过), ROS 用右手系且约定 x 前、y 左、z 上——注意 y 轴方向和 CARLA 相反。

③ TF 树:ROS2 靠 TF 描述坐标关系,需要正确发布 map → odom → base_link → sensor 这条链。

④ 点云格式:PointCloud2 是二进制打包格式, 字段定义要写对,否则 rviz2 里什么都看不到。

第三步:完整桥接节点(本节核心产出)

pythoncarla_ros2_bridge.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CARLA → ROS2 Humble 轻量桥接节点

只发布项目实际需要的话题,避免官方 bridge 的版本依赖问题。

发布话题:
    /carla/camera/image     sensor_msgs/Image
    /carla/camera/info      sensor_msgs/CameraInfo
    /carla/lidar/points     sensor_msgs/PointCloud2
    /carla/odom             nav_msgs/Odometry
    /tf                     坐标变换树

运行前:
    use_ros                      # 切到 ROS2 环境(见本节 bashrc 配置)
    pip install --user carla==0.9.15
    python3 carla_ros2_bridge.py
"""

import math
import queue

import numpy as np
import carla

import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy

from sensor_msgs.msg import Image, CameraInfo, PointCloud2, PointField
from nav_msgs.msg import Odometry
from geometry_msgs.msg import TransformStamped, Quaternion
from tf2_ros import TransformBroadcaster
from std_msgs.msg import Header


IMG_W, IMG_H, FOV = 1280, 720, 90.0
FPS = 20


def euler_to_quat(roll, pitch, yaw):
    """欧拉角(弧度)→ 四元数"""
    cy, sy = math.cos(yaw * 0.5), math.sin(yaw * 0.5)
    cp, sp = math.cos(pitch * 0.5), math.sin(pitch * 0.5)
    cr, sr = math.cos(roll * 0.5), math.sin(roll * 0.5)
    q = Quaternion()
    q.w = cr * cp * cy + sr * sp * sy
    q.x = sr * cp * cy - cr * sp * sy
    q.y = cr * sp * cy + sr * cp * sy
    q.z = cr * cp * sy - sr * sp * cy
    return q


class CarlaBridge(Node):

    def __init__(self):
        super().__init__("carla_ros2_bridge")

        # 传感器数据用 BEST_EFFORT,避免网络抖动时阻塞
        qos = QoSProfile(
            reliability=ReliabilityPolicy.BEST_EFFORT,
            history=HistoryPolicy.KEEP_LAST,
            depth=5,
        )

        self.pub_img = self.create_publisher(Image, "/carla/camera/image", qos)
        self.pub_info = self.create_publisher(CameraInfo, "/carla/camera/info", qos)
        self.pub_pcd = self.create_publisher(PointCloud2, "/carla/lidar/points", qos)
        self.pub_odom = self.create_publisher(Odometry, "/carla/odom", 10)
        self.tf_broadcaster = TransformBroadcaster(self)

        self.get_logger().info("连接 CARLA...")
        self._setup_carla()
        self.get_logger().info("桥接就绪,开始发布话题")

        # 主循环定时器
        self.timer = self.create_timer(1.0 / FPS, self.tick)

    # ---------------- CARLA 侧 ----------------
    def _setup_carla(self):
        self.client = carla.Client("localhost", 2000)
        self.client.set_timeout(15.0)
        self.world = self.client.get_world()

        settings = self.world.get_settings()
        settings.synchronous_mode = True
        settings.fixed_delta_seconds = 1.0 / FPS
        self.world.apply_settings(settings)
        self.tm = self.client.get_trafficmanager(8000)
        self.tm.set_synchronous_mode(True)

        bp_lib = self.world.get_blueprint_library()

        # 主车
        v_bp = bp_lib.filter("vehicle.tesla.model3")[0]
        spawns = self.world.get_map().get_spawn_points()
        self.vehicle = None
        for sp in spawns:
            self.vehicle = self.world.try_spawn_actor(v_bp, sp)
            if self.vehicle:
                break
        if self.vehicle is None:
            raise RuntimeError("无法生成主车")
        self.vehicle.set_autopilot(True, self.tm.get_port())

        # 相机
        c_bp = bp_lib.find("sensor.camera.rgb")
        c_bp.set_attribute("image_size_x", str(IMG_W))
        c_bp.set_attribute("image_size_y", str(IMG_H))
        c_bp.set_attribute("fov", str(FOV))
        c_bp.set_attribute("sensor_tick", "0.0")
        self.camera = self.world.spawn_actor(
            c_bp, carla.Transform(carla.Location(x=1.5, z=2.0)),
            attach_to=self.vehicle)
        self.q_img = queue.Queue()
        self.camera.listen(self.q_img.put)

        # 激光雷达
        l_bp = bp_lib.find("sensor.lidar.ray_cast")
        l_bp.set_attribute("channels", "64")
        l_bp.set_attribute("range", "80")
        l_bp.set_attribute("points_per_second", "600000")
        l_bp.set_attribute("rotation_frequency", str(FPS))
        l_bp.set_attribute("sensor_tick", "0.0")
        self.lidar = self.world.spawn_actor(
            l_bp, carla.Transform(carla.Location(x=0.0, z=2.4)),
            attach_to=self.vehicle)
        self.q_pcd = queue.Queue()
        self.lidar.listen(self.q_pcd.put)

        # 预计算相机内参(1.4 节的公式)
        f = IMG_W / (2.0 * math.tan(math.radians(FOV) / 2.0))
        self.K = [f, 0.0, IMG_W / 2.0,
                  0.0, f, IMG_H / 2.0,
                  0.0, 0.0, 1.0]

    # ---------------- 主循环 ----------------
    def tick(self):
        frame = self.world.tick()
        stamp = self.get_clock().now().to_msg()

        # 取本帧数据(帧号校验,见 1.2 节)
        try:
            img = self._take(self.q_img, frame)
            pcd = self._take(self.q_pcd, frame)
        except queue.Empty:
            self.get_logger().warn("传感器数据超时,跳过本帧")
            return

        self._pub_tf(stamp)
        self._pub_odom(stamp)
        self._pub_image(img, stamp)
        self._pub_pointcloud(pcd, stamp)

    def _take(self, q, frame_id, timeout=2.0):
        while True:
            data = q.get(timeout=timeout)
            if data.frame == frame_id:
                return data
            if data.frame > frame_id:
                raise queue.Empty

    # ---------------- 各话题发布 ----------------
    def _pub_tf(self, stamp):
        """
        发布 TF 树: map → base_link → {camera_link, lidar_link}
        
        坐标系转换要点(1.4 节讲过 CARLA 是左手系):
            CARLA: x前 y右 z上
            ROS  : x前 y左 z上   ← y 轴方向相反!
        所以 y 和 yaw 都要取负。
        """
        tf = self.vehicle.get_transform()

        t = TransformStamped()
        t.header.stamp = stamp
        t.header.frame_id = "map"
        t.child_frame_id = "base_link"
        t.transform.translation.x = tf.location.x
        t.transform.translation.y = -tf.location.y          # 左手→右手
        t.transform.translation.z = tf.location.z
        t.transform.rotation = euler_to_quat(
            math.radians(tf.rotation.roll),
            -math.radians(tf.rotation.pitch),
            -math.radians(tf.rotation.yaw))                 # 同样取负
        self.tf_broadcaster.sendTransform(t)

        # 传感器相对车身的静态变换
        for child, loc in [("camera_link", (1.5, 0.0, 2.0)),
                            ("lidar_link", (0.0, 0.0, 2.4))]:
            s = TransformStamped()
            s.header.stamp = stamp
            s.header.frame_id = "base_link"
            s.child_frame_id = child
            s.transform.translation.x = loc[0]
            s.transform.translation.y = loc[1]
            s.transform.translation.z = loc[2]
            s.transform.rotation.w = 1.0
            self.tf_broadcaster.sendTransform(s)

    def _pub_odom(self, stamp):
        tf = self.vehicle.get_transform()
        vel = self.vehicle.get_velocity()

        odom = Odometry()
        odom.header.stamp = stamp
        odom.header.frame_id = "map"
        odom.child_frame_id = "base_link"
        odom.pose.pose.position.x = tf.location.x
        odom.pose.pose.position.y = -tf.location.y
        odom.pose.pose.position.z = tf.location.z
        odom.pose.pose.orientation = euler_to_quat(
            math.radians(tf.rotation.roll),
            -math.radians(tf.rotation.pitch),
            -math.radians(tf.rotation.yaw))
        odom.twist.twist.linear.x = vel.x
        odom.twist.twist.linear.y = -vel.y
        odom.twist.twist.linear.z = vel.z
        self.pub_odom.publish(odom)

    def _pub_image(self, carla_img, stamp):
        # CARLA 给的是 BGRA
        arr = np.frombuffer(carla_img.raw_data, dtype=np.uint8)
        arr = arr.reshape((IMG_H, IMG_W, 4))[:, :, :3]   # 取 BGR

        msg = Image()
        msg.header.stamp = stamp
        msg.header.frame_id = "camera_link"
        msg.height = IMG_H
        msg.width = IMG_W
        msg.encoding = "bgr8"
        msg.is_bigendian = 0
        msg.step = IMG_W * 3
        msg.data = arr.tobytes()
        self.pub_img.publish(msg)

        # 相机内参(下游做投影要用)
        info = CameraInfo()
        info.header = msg.header
        info.height = IMG_H
        info.width = IMG_W
        info.distortion_model = "plumb_bob"
        info.d = [0.0] * 5                      # CARLA 无畸变
        info.k = self.K
        info.p = [self.K[0], 0.0, self.K[2], 0.0,
                  0.0, self.K[4], self.K[5], 0.0,
                  0.0, 0.0, 1.0, 0.0]
        self.pub_info.publish(info)

    def _pub_pointcloud(self, carla_pcd, stamp):
        """
        转 PointCloud2 —— 字段定义必须精确,否则 rviz2 显示不出来
        """
        pts = np.frombuffer(carla_pcd.raw_data, dtype=np.float32)
        pts = pts.reshape(-1, 4).copy()          # x, y, z, intensity

        # CARLA 左手系 → ROS 右手系:y 取负
        pts[:, 1] = -pts[:, 1]

        msg = PointCloud2()
        msg.header.stamp = stamp
        msg.header.frame_id = "lidar_link"
        msg.height = 1                            # 无序点云
        msg.width = pts.shape[0]
        msg.is_dense = True
        msg.is_bigendian = False
        msg.fields = [
            PointField(name="x", offset=0,
                       datatype=PointField.FLOAT32, count=1),
            PointField(name="y", offset=4,
                       datatype=PointField.FLOAT32, count=1),
            PointField(name="z", offset=8,
                       datatype=PointField.FLOAT32, count=1),
            PointField(name="intensity", offset=12,
                       datatype=PointField.FLOAT32, count=1),
        ]
        msg.point_step = 16                       # 4 个 float32
        msg.row_step = msg.point_step * msg.width
        msg.data = pts.tobytes()
        self.pub_pcd.publish(msg)

    # ---------------- 清理 ----------------
    def destroy_node(self):
        self.get_logger().info("清理 CARLA actor...")
        for s in [self.camera, self.lidar]:
            try: s.stop(); s.destroy()
            except Exception: pass
        try: self.vehicle.destroy()
        except Exception: pass
        # 恢复异步,避免影响别人
        try:
            st = self.world.get_settings()
            st.synchronous_mode = False
            st.fixed_delta_seconds = None
            self.world.apply_settings(st)
            self.tm.set_synchronous_mode(False)
        except Exception: pass
        super().destroy_node()


def main():
    rclpy.init()
    node = CarlaBridge()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == "__main__":
    main()

第四步:运行与可视化

bash
# ---------- 终端 1: CARLA 服务端 ----------
cd ~/carla && QUALITY=Low ./start_carla.sh

# ---------- 终端 2: 桥接节点 ----------
use_ros                              # 切 ROS2 环境(不要激活 conda)
pip install --user carla==0.9.15     # 首次需要
python3 carla_ros2_bridge.py

# ---------- 终端 3: 验证话题 ----------
use_ros

# 列出所有话题
ros2 topic list

# 看发布频率(应接近 20Hz)
ros2 topic hz /carla/camera/image
ros2 topic hz /carla/lidar/points

# 看一条消息的结构
ros2 topic echo /carla/odom --once

# 检查 TF 树是否完整
ros2 run tf2_tools view_frames
# 会生成 frames.pdf,应看到 map → base_link → camera_link/lidar_link

# ---------- 终端 4: rviz2 可视化 ----------
use_ros
rviz2

# rviz2 里手动添加显示项:
#   1. Fixed Frame 设为 map
#   2. Add → Image      → Topic 选 /carla/camera/image
#   3. Add → PointCloud2 → Topic 选 /carla/lidar/points
#   4. Add → Odometry    → Topic 选 /carla/odom
#   5. Add → TF
# 注意 QoS: 传感器话题要把 Reliability 设成 Best Effort
▸ 服务器上怎么看 rviz2

L20 是远程服务器,rviz2 是 GUI 程序。两种方案:

① X11 转发(简单,但卡):ssh -X l20 然后直接跑 rviz2

② 本地跑 rviz2(推荐):本地机器也装 ROS2 Humble, 设相同的 ROS_DOMAIN_ID,两台机器在同一局域网就能自动发现—— 这才是 ROS2 分布式通信的正确用法。

bash
# 两台机器都设置相同 domain id
export ROS_DOMAIN_ID=42

# L20 上跑桥接,本地跑 rviz2,话题自动互通

常见报错与排查

现象原因解决
ModuleNotFoundError: rclpy 没 source ROS2,或在 conda 环境里 conda deactivate 后 source /opt/ros/humble/setup.bash
ModuleNotFoundError: carla carla 装在 conda 环境,ROS2 用的是系统 Python 在 ROS2 环境里 pip install --user carla==0.9.15
numpy 版本冲突报错 conda 的 numpy 污染了系统 Python 路径 检查 PYTHONPATH,确保没混入 conda 路径
rviz2 里点云不显示 QoS 不匹配(发布是 BEST_EFFORT) rviz2 中把该显示项的 Reliability 改成 Best Effort
rviz2 报 "No TF data" Fixed Frame 设错,或 TF 没发出来 Fixed Frame 设为 map;用 ros2 run tf2_tools view_frames 检查
点云左右镜像 忘了 CARLA 左手系 → ROS 右手系的 y 取负 检查 pts[:,1] = -pts[:,1] 是否执行
两台机器话题不互通 ROS_DOMAIN_ID 不一致或防火墙 两边设相同 ID;检查 UDP 端口是否放行
话题频率远低于 20Hz GPU 被占满,CARLA tick 变慢 QUALITY=Low;降低点云 points_per_second

验收清单

  • 理解三条集成路线的取舍,能说清为什么本课程推荐自写桥接
  • ROS2 Humble 安装完成,ros2 --version 正常
  • 配好 use_ros / use_ad 切换函数,理解为什么要隔离 conda 与 ROS2
  • 桥接节点跑通,ros2 topic list 能看到全部 5 个话题
  • ros2 topic hz 显示频率接近 20Hz
  • TF 树完整:map → base_link → camera_link / lidar_link
  • rviz2 里能同时看到图像、点云、里程计轨迹,且点云方向正确(不镜像)

本节小结

  • Ubuntu 22.04 + ROS2 Humble 是最佳组合,可原生 apt 安装,无需 Docker
  • 自写轻量桥接优于官方 bridge:版本无关、只发需要的话题、面试价值更高
  • conda 与 ROS2 必须隔离——不要在同一终端同时激活,用切换函数管理
  • CARLA 左手系 → ROS 右手系:y 和 yaw 都要取负(1.4 节坐标系知识的延续)
  • PointCloud2 的字段定义要精确,point_step=16 对应 4 个 float32
  • 远程可视化推荐本地跑 rviz2 + 相同 ROS_DOMAIN_ID,这才是 ROS2 分布式的正确用法
▸ M1 模块到此完成

你现在拥有:可训练的 Python 环境、能采数据的 CARLA、真实数据集 nuScenes、 标定验证工具、以及 ROS2 通路。地基打完了。

下一模块 M2 开始进入算法主线——采集 BEV 训练数据、训练 BEVFormer。 那时你会发现 M1 这些准备每一样都用得上。