1. 为什么点云和图像融合是自动驾驶的“黄金搭档”?

大家好,我是老张,在自动驾驶和机器人感知领域摸爬滚打了十几年。今天想和大家聊聊一个既基础又核心的技术:激光雷达点云与相机图像的融合。这听起来可能有点学术,但我保证,用大白话讲清楚后,你会发现它其实就像给机器人装上“立体眼镜”,让它能同时看清世界的“形状”和“颜色”。

想象一下,你开车时,眼睛告诉你前面有个红色的物体(图像信息),而你的空间感告诉你它离你大概5米远,有半米高(类似点云的距离和三维形状信息)。自动驾驶系统也需要这种能力。单靠相机,它就像个“色盲”的近视眼,能看清纹理和颜色,但判断距离和三维轮廓很吃力;单靠激光雷达,它又像个“高度近视”,能精准感知物体的远近和轮廓,却分不清那是红色的停止标志还是一块红色的广告牌。

点云与图像的融合,就是为了解决这个“感官分裂”的问题。 它的核心目标,是把激光雷达扫描得到的成千上万个三维空间点(每个点都有精确的XYZ坐标),准确地“投射”到相机拍摄的二维图片上。这个过程,我们称之为 “重投影”。成功之后,系统就能知道图片中每一个像素点,在真实世界里对应哪个三维位置,距离有多远。这对于障碍物检测、车道线识别、可行驶区域分割等任务来说,简直是如虎添翼。

但是,想把这件事做准,有个大前提:你必须知道激光雷达和相机这“哥俩”之间的精确位置和朝向关系。这个关系,就是我们常说的 “外参”。它通常用一个4x4的变换矩阵来表示。怎么得到这个矩阵呢?业内常用Autoware这类开源工具进行联合标定。不过,坑就在这里——从Autoware直接拿到的标定结果,并不能直接往代码里一扔了事,里面有几个关键的“弯”要转过来,否则投影结果会错得离谱。

我刚开始做融合的时候,就曾因为没处理好这个外参矩阵,导致点云全部投影到了图像之外,调试了大半天。所以,今天这篇文章,我就手把手带你用Python走通整个流程,重点攻克Autoware标定结果的处理难关,并展示最终的融合效果。咱们不玩虚的,直接上代码和实战经验。

2. 环境准备与数据获取:万事开头“细”

工欲善其事,必先利其器。咱们先来把“厨房”收拾好。我强烈建议使用 Anaconda 来管理Python环境,它能让你避免各种依赖包版本冲突的“玄学”问题。

打开你的终端(Linux/Mac)或命令提示符/PowerShell(Windows),我们一步步来:

# 1. 创建一个新的Python环境,命名为 lidar_cam_fusion,Python版本用3.8比较稳妥
conda create --name lidar_cam_fusion python=3.8 -y

# 2. 激活这个环境
conda activate lidar_cam_fusion

# 3. 安装核心的科学计算和视觉库
conda install numpy matplotlib opencv -y

# 4. 安装点云处理利器 Open3D
pip install open3d

# 5. 顺便也把常用的数据操作库装上
pip install pandas scipy

环境装好后,我们来准备数据。你需要两样东西:

  1. 一张相机图片:最好是.jpg.png格式。这是我们的“画布”。
  2. 一帧激光雷达点云:通常是.pcd.bin格式。这是我们要投射到画布上的“颜料”。
  3. 最重要的:标定结果文件。使用Autoware完成激光雷达与相机的联合标定后,你会得到一个.yaml文件。它的内容大致长这样:
%YAML:1.0
---
CameraExtrinsicMat: !!opencv-matrix
   rows: 4
   cols: 4
   dt: d
   data: [ 6.438e-02, -2.961e-02,  9.975e-01,  7.057e-03,
           -9.978e-01, -1.829e-02,  6.386e-02,  6.966e-02,
            1.636e-02, -9.994e-01, -3.073e-02,  7.396e-02,
            0., 0., 0., 1. ]
CameraMat: !!opencv-matrix
   rows: 3
   cols: 3
   dt: d
   data: [ 6.009e+02, 0., 3.051e+02,
           0., 6.117e+02, 2.527e+02,
           0., 0., 1. ]
DistCoeff: !!opencv-matrix
   rows: 1
   cols: 5
   dt: d
   data: [ 2.303e-01, -9.156e-01,  1.037e-02, -8.966e-04,  1.351e+00 ]
ImageSize: [640, 480]
ReprojectionError: 4.497e-01

这个文件里包含了我们需要的所有“钥匙”:CameraExtrinsicMat(外参矩阵)、CameraMat(内参矩阵)、DistCoeff(畸变系数)。但请注意,CameraExtrinsicMat这把钥匙是拧反的,需要我们先“加工”一下才能开门

3. 核心难点:Autoware标定结果的“正确打开方式”

这是全文最核心、最容易出错的部分。很多新手(包括当年的我)直接把这个矩阵扔进投影公式,结果发现点云要么不见了,要么位置完全不对。问题出在坐标系的定义和矩阵的存储格式上。

Autoware输出的 CameraExtrinsicMat,定义的是 从相机坐标系到激光雷达坐标系的变换。但我们在做重投影时,需要的恰恰是反过来的变换:将点云从激光雷达坐标系变换到相机坐标系。所以,我们需要对这个矩阵进行求逆操作。不过,这里有一个更常见的处理技巧,因为Autoware给出的这个矩阵本身具有特殊结构。

让我们仔细看这个4x4矩阵,它其实是一个刚体变换矩阵,可以分解为一个3x3的旋转矩阵 R 和一个3x1的平移向量 t。对于重投影,我们需要的旋转矩阵是 R转置 (R.T),而平移向量也需要进行相应的坐标轴变换。

具体操作步骤(务必记牢):

  1. 旋转矩阵处理:取出外参矩阵左上角3x3部分作为 R。先对其进行 转置,然后通过 罗德里格斯变换 将其转换为旋转向量 rvec。这是因为OpenCV的 projectPoints 函数需要的是旋转向量格式。
  2. 平移向量处理:取出外参矩阵的第四列前三个数作为 t。然后按照 [ -t[2], t[0], t[1] ] 的规则进行换位和取反,得到真正的平移向量 tvec

为什么是 -t[2]?这涉及到Autoware(或某些标定工具)与OpenCV/常见视觉库之间坐标系定义的差异(通常是Z轴方向相反)。这个转换是经验性的,也是无数人踩坑后总结出来的。

下面我们用代码来演示这个关键转换过程,我会在注释里详细解释每一步:

import cv2
import numpy as np

# 假设我们从yaml文件中读取到了原始外参矩阵
# 这里我们直接用上面例子中的数据
extrinsic_data = [6.4384993725688400e-02, -2.9614494224688315e-02, 9.9748561609416664e-01, 7.0567131528775830e-03,
                  -9.9779108558785690e-01, -1.8293328335903247e-02, 6.3861597692206229e-02, 6.9659648287774212e-02,
                  1.6356102969515951e-02, -9.9939398430759574e-01, -3.0726894171716257e-02, 7.3964965903196039e-02,
                  0., 0., 0., 1.]

# 将列表重塑为4x4矩阵
CameraExtrinsicMat = np.array(extrinsic_data, dtype=np.float64).reshape(4, 4)
print("原始外参矩阵 (CameraExtrinsicMat):")
print(CameraExtrinsicMat)

# 步骤1:提取旋转矩阵R(前3行,前3列)
R = CameraExtrinsicMat[:3, :3]
print("\n提取的旋转矩阵 R:")
print(R)

# 关键操作1:对R进行转置
R_transposed = np.transpose(R)
print("\n转置后的旋转矩阵 R.T:")
print(R_transposed)

# 关键操作2:将旋转矩阵转换为旋转向量 (Rodrigues变换)
# cv2.Rodrigues() 输入旋转矩阵,返回旋转向量和雅可比矩阵,我们取第一个返回值
rvec, _ = cv2.Rodrigues(R_transposed)
print("\n罗德里格斯变换后的旋转向量 rvec:")
print(rvec)

# 步骤2:提取平移向量t(第4列的前3行)
t = CameraExtrinsicMat[:3, 3]
print("\n提取的平移向量 t:")
print(t)

# 关键操作3:对平移向量进行换位处理
# 规则: t_true = [ -t[2], t[0], t[1] ]
tvec = np.array([[-t[2]], [t[0]], [t[1]]], dtype=np.float64)
print("\n转换后的真实平移向量 tvec:")
print(tvec)

# 内参矩阵和畸变系数通常可以直接使用
camera_matrix = np.array([[6.0094877060462500e+02, 0, 3.0507696130640221e+02],
                          [0, 6.1174212550675293e+02, 2.5274596287337977e+02],
                          [0, 0, 1]], dtype=np.float64)

dist_coeffs = np.array([2.3030430710414049e-01, -9.1560321189489913e-01,
                        1.0374975865423207e-02, -8.9662215743119679e-04,
                        1.3506515085650497e+00], dtype=np.float64)

print("\n相机内参矩阵 K:")
print(camera_matrix)
print("\n畸变系数 distCoeffs:")
print(dist_coeffs)

运行这段代码,你会看到转换前后的数据对比。rvectvec 才是我们后续进行点云投影时真正需要的姿态参数。 很多开源代码融合效果差,问题十有八九就出在没做这个转换上。

4. 实战:从PCD文件到图像像素的完整投影流程

好了,钥匙加工好了,现在我们来开门。整个重投影的流程可以概括为:读取点云 -> 坐标变换 -> 投影计算 -> 可视化。我们一步步拆解。

首先,我们需要读取激光雷达点云文件。这里以 .pcd 格式为例,使用我们安装好的 open3d 库。

import open3d as o3d
import numpy as np

# 读取点云文件
pcd_path = 'your_lidar_data.pcd'  # 替换为你的点云文件路径
point_cloud = o3d.io.read_point_cloud(pcd_path)

# 将点云数据转换为Numpy数组,形状为 (N, 3), N是点数
# 每一行是一个点 [x, y, z]
points_3d = np.asarray(point_cloud.points)
print(f"成功读取点云,共有 {points_3d.shape[0]} 个点")

接下来,就是最激动人心的时刻:调用OpenCV的 cv2.projectPoints 函数,将三维点投影到二维图像平面。这个函数封装了完整的相机模型,包括内参、外参和畸变矫正。

import cv2

# 使用上一节计算得到的 rvec, tvec, camera_matrix, dist_coeffs
# 假设我们已经有了这些变量
# rvec, tvec, camera_matrix, dist_coeffs

# 进行投影变换
# 输入: 3D点集 (N, 1, 3), 旋转向量,平移向量,相机内参,畸变系数
# 输出: 2D像素坐标 (N, 1, 2), 雅可比矩阵(这里用不到)
points_2d, _ = cv2.projectPoints(points_3d.reshape(-1, 1, 3),
                                  rvec, tvec,
                                  camera_matrix, dist_coeffs)

# 将输出形状从 (N, 1, 2) 转换为 (N, 2)
points_2d = points_2d.reshape(-1, 2)
print(f"投影完成,前5个点的像素坐标为:\n{points_2d[:5]}")

现在,每个激光点都有了对应的图像像素坐标 (u, v)。但是,并不是所有点都能落在图像范围内(比如点云中车身后的点)。我们需要进行过滤,只保留那些在图像边界内的点。

# 读取对应的相机图像,获取图像尺寸
image_path = 'your_camera_image.jpg'  # 替换为你的图像路径
image = cv2.imread(image_path)
height, width = image.shape[:2]
print(f"图像尺寸:宽 {width}, 高 {height}")

# 过滤出落在图像范围内的点
valid_indices = (points_2d[:, 0] >= 0) & (points_2d[:, 0] < width) & \
                (points_2d[:, 1] >= 0) & (points_2d[:, 1] < height)

filtered_points_2d = points_2d[valid_indices]
filtered_points_3d = points_3d[valid_indices] # 对应的3D点也保留,后续可能用到深度信息

print(f"有效投影点数量:{len(filtered_points_2d)} / {len(points_2d)}")

5. 效果可视化:当点云“染”上图像的颜色

数据算出来了,不看到效果总觉得不踏实。我们可以用 matplotlib 把点云叠加到图像上显示。为了更直观,我们还可以用点的深度(Z坐标)来给点云上色,距离近的点用暖色(如红色),远的点用冷色(如蓝色)。

import matplotlib.pyplot as plt
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Normalize

# 创建一个图形
plt.figure(figsize=(15, 8))

# 显示原始图像
plt.imshow(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))

# 获取有效点的深度值(通常是相机坐标系下的Z值,即tvec方向)
# 注意:我们需要将LiDAR点转换到相机坐标系下来计算深度
# 方法一:使用转换后的点(更准确)。这里我们用一种简化方法,使用原始点云的Z轴近似,但严格来说应该用变换后的。
# 我们这里演示根据投影前的3D点距离原点的距离来着色(仅作示意,理想情况应用相机坐标系下的Z值)
depth_values = np.linalg.norm(filtered_points_3d, axis=1)
# 归一化深度值用于颜色映射
norm = Normalize(vmin=depth_values.min(), vmax=depth_values.max())
cmap = plt.cm.viridis  # 使用viridis色彩映射,也可以选jet, plasma等

# 绘制散点,颜色根据深度变化
scatter = plt.scatter(filtered_points_2d[:, 0], filtered_points_2d[:, 1],
                      c=depth_values, cmap=cmap, s=1, alpha=0.6, # s是点大小,alpha是透明度
                      edgecolors='none') # 去掉边缘色,更清晰

# 添加颜色条
cbar = plt.colorbar(scatter, ax=plt.gca())
cbar.set_label('深度 (距离)', fontsize=12)

plt.title('激光雷达点云与图像融合效果 (颜色代表深度)', fontsize=14)
plt.axis('off') # 不显示坐标轴
plt.tight_layout()
plt.show()

运行这段代码,你就能得到一张点云叠加在图像上的效果图。如果标定和处理都正确,你会看到道路上的车辆、行人、树木等物体的轮廓,被密集的点云精确地勾勒出来,并且距离信息通过颜色清晰可见。

一个重要的优化技巧:直接绘制几万个点可能会比较慢。在实际应用中,特别是需要实时显示时,可以考虑对点云进行体素下采样(使用Open3D的 voxel_down_sample)来减少点数,或者使用更高效的渲染方法。

6. 进阶技巧与避坑指南

做到上面那一步,基础融合已经完成了。但想在实际项目中用好,还有几个坑和技巧需要了解。

避坑指南1:畸变矫正的顺序 我们的流程是:先对3D点进行投影(函数内部会考虑畸变),得到畸变后的像素坐标。如果你的图像是已经去畸变的,那么cv2.projectPoints中的distCoeffs应该设为零。千万不要自己先用cv2.undistort把图像矫正了,然后又用带畸变系数的参数去投影,那样会导致错位。

避坑指南2:点云过滤与 ROI 设置 激光雷达的视野和相机视野并不完全重合。通常激光雷达是360度,而相机只有前向几十度。大量点云投影到图像外是正常的。除了根据图像边界过滤,还可以根据业务逻辑提前过滤点云。例如,只保留相机前方一定距离和角度范围内的点,可以大幅提升处理速度。

# 示例:只保留LiDAR坐标系下,前方(X>0)且在一定距离内的点
mask = (points_3d[:, 0] > 0) & (points_3d[:, 0] < 50) # 假设X轴向前,保留前方50米内的点
points_3d = points_3d[mask]

进阶技巧1:给点云着色 除了用深度着色,更常见的是根据点云的反射强度(Intensity)着色,或者直接使用相机图像对应像素的颜色给点云上色。后者能生成非常漂亮的彩色点云。

# 获取点云对应像素的RGB颜色(假设points_2d是整数像素坐标)
colors = []
for pt in filtered_points_2d.astype(int):
    # 注意OpenCV图像是BGR顺序,matplotlib是RGB
    bgr_color = image[pt[1], pt[0]] # 图像索引是 [行, 列],即 [v, u]
    rgb_color = [bgr_color[2], bgr_color[1], bgr_color[0]]
    colors.append(rgb_color)
colors = np.array(colors) / 255.0 # 归一化到0-1

# 然后用这个colors数组去绘制点云

进阶技巧2:使用更鲁棒的标定验证 Autoware标定结果中的 ReprojectionError 是一个重要的参考指标,它表示重投影误差的均方根值,单位是像素。一般来说,这个值小于1个像素就算很不错了。如果误差很大(比如大于5),你可能需要检查标定过程:棋盘格是否清晰、角点提取是否准确、数据采集时是否有震动等。标定是融合的基石,基石不稳,后面全歪。

7. 在Autoware框架内集成与优化

如果你不仅仅是在写脚本,而是在Autoware这样的ROS框架下开发,那么流程需要封装成节点。这里给出一个简化的ROS节点思路:

  1. 订阅话题:订阅 /points_raw (激光雷达点云) 和 /image_raw (相机图像)。
  2. 时间同步:使用 message_filters 中的 ApproximateTime 策略来确保处理同一时刻的点云和图像,避免因传感器频率不同带来的错位。
  3. 缓存参数:在节点初始化时,从参数服务器或文件加载处理好的 rvec, tvec, camera_matrix, dist_coeffs
  4. 核心处理:在回调函数中,执行我们上面写的投影、过滤、着色逻辑。
  5. 发布结果:将融合后的图像(可以是绘制了点云的图像,也可以是带颜色的点云)发布到新的话题,如 /fusion_image/colored_points

性能优化点

  • 并行计算:对于大规模点云,投影计算是瓶颈。可以考虑使用Numba进行加速,或者利用OpenCV的UMat(透明API)利用GPU。
  • 只处理感兴趣区域:先对图像做目标检测(如YOLO),得到2D框,然后只投影可能落在这些框内的3D点,可以极大减少计算量。
  • 使用C++:如果对实时性要求极高,将核心投影部分用C++实现,并编译成Python扩展模块,速度会有数量级的提升。

最后,我想说的是,传感器融合是一个工程实践性极强的领域。理论公式就那些,但每个数据集、每套传感器、甚至每次安装的微小差异,都可能带来意想不到的问题。多可视化、多验证、从小数据开始调试,是快速定位问题的法宝。希望这篇结合了原理、代码和实战经验的文章,能帮你少走弯路,顺利搞定激光雷达与图像的精准融合。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐