☰
激光雷达点云投影到相机图像:从KITTI到ROS实现
2026/10/7 18:15:37 网站建设 项目流程

做多传感器融合的人大多有过类似经历:设备还没跑起来,第一想看的往往不是精度报表,而是激光雷达的点云能不能准确叠到相机图像上。这个看着像demo级的小功能,实际上串起了坐标系变换、传感器标定、话题同步和可视化这一整条感知链路。我第一次做点云投影时,卡在坐标系变换上整整两天,回头一看核心代码不到五十行。这篇文章就把这条链路从原理到落地完整拆开来讲,以KITTI数据集作为数据入口,最后改造成ROS节点跑起来,保证你看完能复现,不用再走我踩过的弯路。

内容适合刚入坑ROS、正在做感知融合项目或者准备相关毕业设计的同学。如果你只是想把雷达点云投到图像上看看效果,这篇文章也够用,而且会告诉你哪些环节最容易出错、为什么出错。

1. 为什么要做点云投影:激光雷达和相机的互补关系

激光雷达和相机的关系,像是两个各有专长的队友。激光雷达直接给你三维空间里的距离信息,精度高、不受光照影响,但它是稀疏的,没有颜色和纹理;相机正好反过来,图像里有丰富的颜色、边缘、语义信息,但没有直接的距离值。点云投影到图像,本质上就是让这两路数据在同一个参考坐标系下对齐,各取所长。

在工程上这个能力有很多实际用途。比如做目标检测时,图像上的检测框可以借助投影拿到点云内部的深度信息,把2D框扩展成3D框;做语义分割时,可以把图像分割结果映射回点云,给每个点打上语义标签;做SLAM和建图时,投影结果可以辅助回环检测和动态物体剔除。就算不往深了做,单是把点云叠在图像上调试多传感器时间同步和标定参数,也是透视链路是否正常的最直观手段。

所以我的判断是:点云投影应该作为感知融合学习的必修第一课,优先于任何花哨的模型和算法。原因很简单——它涉及到传感器坐标系的搭建逻辑。如果连坐标系对应关系都不清楚,后面做标定、做融合、做BEV感知都会一错千里。

KITTI数据集之所以适合当入门数据,因为它的传感器配置完整——一台64线Velodyne激光雷达、四个相机、以及GPS/IMU,所有传感器之间都已经做了时间和空间同步,标定文件是公开的。不需要你亲自去标定设备,直接拿着官方标定结果就可以验证整个投影流程。这也是很多论文实验的标准数据源,学完一套流程,后续看论文、复现模型都有底子。

2. 投影不是魔法:四个坐标系的一次接力

点云投影到图像,本质上是一个坐标系之间的接力过程。点云里每个点是三维坐标(x, y, z),图像里每个像素是二维坐标(u, v)。把三维点变成二维像素,中间要经过四次坐标变换。

2.1 从激光雷达到相机坐标:靠外参

点云数据是在激光雷达自身坐标系下的。这个坐标系是这样定义的:x轴指向雷达前方(车头方向),y轴指向左侧,z轴指向上方。相机坐标系则不同,通常z轴指向相机前方,x轴指向右,y轴指向下。

要从激光雷达坐标系变换到相机坐标系,需要知道两个传感器之间的相对位姿——旋转矩阵R和平移向量T。这个R和T就是外参,一般通过标定得到。在KITTI里,它们存在calib_velo_to_cam.txt文件中。

变换公式属于最基础的刚体变换:

X_cam = R_velo_to_cam @ X_velo + T_velo_to_cam

2.2 从相机坐标到像素坐标:靠内参和校正矩阵

到了相机坐标系之后,还要投影到图像平面。这一步靠的是相机内参矩阵K,它是这么构造的:

K = [fx 0 cx] [0 fy cy] [0 0 1]

其中fx、fy是焦距(像素单位),cx、cy是光心在像素平面上的位置。有了内参之后,相机坐标系下的点 (x, y, z) 投影到像素 (u, v) 为:

u = fx * x / z + cx v = fy * y / z + cy

注意这里多了一个除以z的操作,因为投影是透视的,深度越小看着越大,这就是相机成像的基本原理。

KITTI还有一个细节,就是图像已经做过校正(rectification),所以投影时还要引入一个校正旋转矩阵R_rect_xx,把所有相机转到共同平面上。把内参和校正矩阵合成一个3x4的投影矩阵P_rect_xx,最终公式就是:

[U; V; W] = P_rect_xx @ R_rect_xx @ [R_velo_to_cam | T_velo_to_cam] @ [X; Y; Z; 1] u = U / W v = V / W

看到没有,这就是那个“五十行代码”的核心。外边看是一大堆坐标系,物理上其实就是一个矩阵链相乘。KITTI官方甚至直接把它简化成一个投影矩阵公式:

Y = P_rect_xx * R_rect_xx * T_velo_to_cam * X

2.3 齐次坐标:让平移也能写成矩阵乘法

上面公式里多了个4x1的[X; Y; Z; 1],这就是齐次坐标。为什么要多补一个1?因为旋转是线性变换,可以写成矩阵乘法,但平移不是。硬要把它也塞进矩阵乘法里,就需要把三维点升到四维,用4x4矩阵一次性表达“旋转+平移”。

这个习惯非常容易踩坑。KITTI点云bin文件的每个点有4个分量(x, y, z, intensity),第四维是激光反射强度,不是齐次坐标里的1。很多新手直接把整列数据拿去和4x4矩阵相乘,结果投影出来一片混乱。正确做法是把前三列取出来,手动补一列1,跳过intensity。

3. KITTI数据准备:先把手里的数据搞清楚

3.1 下载哪些文件

KITTI官网的数据结构比较分散,第一次接触很容易下错。做投影任务,核心需要两块内容:

第一块是原始数据包。在官网raw data页面下载任意一个sync后的数据包即可。比如常用的2011_09_26_drive_0005_sync,解压后是一整套同步好的传感器数据。如果网速不理想,也可以用KITTI官网提供的下载脚本按需拉取,不必整包下载。

第二块是标定文件。压缩包长这样:2011_09_26_calib.zip,里面是所有相机和雷达的标定结果。很多新手下载了图像数据和点云数据,结果找不到标定文件,就是这个原因——标定文件是单独存放的,不在sync数据包里。

3.2 目录结构长什么样

以2011_09_26为例,目录结构大致如下:

2011_09_26_drive_0005_sync/ ├── image_00/data/ # 左灰度相机 ├── image_01/data/ # 右灰度相机 ├── image_02/data/ # 左彩色相机 ├── image_03/data/ # 右彩色相机 ├── velodyne_points/data/ # 激光雷达点云bin └── oxts/data/ # GPS/IMU数据

文件名都是时间戳,比如0000000000.png、0000000000.bin。sync版本已经把所有传感器按时间对齐好了,直接用序号对应就可以,不需要自己做时间插值。这一点省了很多事。

3.3 标定文件到底写了什么

解压2011_09_26_calib.zip后,重点看两个文件:

文件作用关键字段
calib_velo_to_cam.txt雷达到相机的位姿变换,即外参R、T
calib_cam_to_cam.txt相机内参、畸变、校正及投影S_xx、K_xx、D_xx、R_rect_xx、P_rect_xx

calib_cam_to_cam.txt内容非常长,但投影时真正需要的是两个矩阵:一个是R_rect_00,用于把相机坐标校正到共同平面;另一个是P_rect_02,因为image_02是左彩色相机,一般彩色投影都使用它。P_rect本身已经包含了内参和校正,所以直接用即可。

如果把calib_cam_to_cam.txt里的P矩阵和calib_velo_to_cam.txt里的R、T拼起来,就得到完整的投影链:

T_velo_to_cam = np.hstack((R_velo_to_cam, T_velo_to_cam.reshape(3, 1))) T_velo_to_cam = np.vstack((T_velo_to_cam, [0, 0, 0, 1])) P_rect = np.zeros((3, 4)) P_rect[:3, :3] = np.eye(3) # 用P_rect_02实际值覆盖

这里要提醒一句:KITTI里P_rect_02是校正后的投影矩阵,如果直接拿它乘上原始齐次点,等价于已经包含了R_rect_02,但保险起见,按照官方公式Y = P_rect_xx * R_rect_xx * T_velo_to_cam * X逐层乘,条理更清晰。

4. 5分钟跑通核心投影:纯Python脚本

环境准备只需要三样:Python 3、NumPy、OpenCV。不需要ROS也能先验证整个流程。ROS部分放到下一章。

4.1 加载标定文件

把KITTI标定信息读取进来并组装成矩阵。注意KITTI标定文件是文本格式,按行解析即可:

import numpy as np import cv2 def load_calib(calib_dir): # 读取外参 with open(calib_dir + '/calib_velo_to_cam.txt', 'r') as f: lines = f.readlines() for line in lines: if 'R:' in line: R_velo_to_cam = np.array([float(x) for x in line.split(':')[1].split()]).reshape(3, 3) if 'T:' in line: T_velo_to_cam = np.array([float(x) for x in line.split(':')[1].split()]).reshape(3, 1) T_velo_to_cam = np.vstack((np.hstack((R_velo_to_cam, T_velo_to_cam)), [0, 0, 0, 1])) # 读取相机内参和校正矩阵 with open(calib_dir + '/calib_cam_to_cam.txt', 'r') as f: lines = f.readlines() for line in lines: if 'R_rect_00:' in line: R_rect_00 = np.array([float(x) for x in line.split(':')[1].split()]).reshape(3, 3) if 'P_rect_02:' in line: P_rect_02 = np.array([float(x) for x in line.split(':')[1].split()]).reshape(3, 4) R_rect_00 = np.vstack((np.hstack((R_rect_00, np.zeros((3, 1)))), [0, 0, 0, 1])) return T_velo_to_cam, R_rect_00, P_rect_02

这段代码做的事情很直白:把文本里的矩阵抠出来,装成齐次形式,留待后面乘。如果你用的是其他数据集,比如自己标定出来的数据,只要保证矩阵维度对齐即可。

4.2 加载点云并投影

def load_point_cloud(bin_file): scan = np.fromfile(bin_file, dtype=np.float32).reshape(-1, 4) return scan def project_velo_to_image(velo_points, T_velo_to_cam, R_rect_00, P_rect_02): # 取前三列并补齐次坐标 pts_velo = velo_points[:, :3] pts_velo = np.hstack((pts_velo, np.ones((pts_velo.shape[0], 1)))) # velodyne -> camera -> rectified pts_cam = (R_rect_00 @ (T_velo_to_cam @ pts_velo.T)).T # 过滤掉相机后面的点 mask = pts_cam[:, 2] > 0 pts_cam = pts_cam[mask] # 投影 pts_img = (P_rect_02 @ pts_cam.T).T u = pts_img[:, 0] / pts_img[:, 2] v = pts_img[:, 1] / pts_img[:, 2] depth = pts_cam[:, 2] return u, v, depth, mask

4.3 可视化叠加

把点画到图像上,可以用深度做颜色映射,近处红色、远处蓝色。这一步操作网上有无数种风格,但原理都一样:给每个有效像素上色。

def draw_projection(image, u, v, depth): h, w, _ = image.shape valid = (u >= 0) & (u < w) & (v >= 0) & (v < h) u, v, depth = u[valid], v[valid], depth[valid] # 深度归一化到0-255 depth_norm = cv2.normalize(depth, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) colormap = cv2.applyColorMap(255 - depth_norm, cv2.COLORMAP_JET) for i in range(len(u)): cv2.circle(image, (int(u[i]), int(v[i])), 2, (int(colormap[i][0][0]), int(colormap[i][0][1]), int(colormap[i][0][2])), -1) return image

跑完这段,如果标定数据没问题、投影代码没写错,你会看到点云精确地覆盖在图像对应物体上:车道线、行人、路边车辆,轮廓严丝合缝。

这里补一个实用判断标准:如果投影结果出现“错位雪花”或者点在物体旁边漂移,第一反应不要改代码,先确认标定矩阵是否读取完整,尤其是calib_cam_to_cam.txt中P矩阵是否对应你用的那台相机。我见过大量出错案例是P_rect_02和P_rect_03搞混,图像和点云当然对不上。

5. 从脚本到ROS节点:把离线流程搬到线上

离线跑通只是第一步,要真正在机器人上实时看到投影效果,还需要把脚本包装成ROS节点,订阅激光雷达点云话题和相机图像话题,在回调函数里完成投影。

5.1 话题类型和格式

ROS里点云通常以sensor_msgs/PointCloud2格式发布,图像是sensor_msgs/Image。如果是从KITTI数据包转出来的bag,话题名可能是/kitti/velo/pointcloud和/kitti/camera_color_left/image_rect。这里要注意:点云的话题名和图像的话题名,不同驱动包的命名差异很大,实际运行前先rostopic list确认。

5.2 节点代码结构

核心思路是:订阅点云和图像话题,都拿到之后做投影,再发布叠加后的结果。

#!/usr/bin/env python3 import rospy import numpy as np import cv2 from sensor_msgs.msg import PointCloud2, Image from sensor_msgs import point_cloud2 from cv_bridge import CvBridge class PointCloudProjector: def __init__(self): rospy.init_node('pointcloud_projector', anonymous=True) self.bridge = CvBridge() self.latest_image = None self.latest_cloud = None # 标定矩阵需要提前加载,省略load_calib步骤 self.T_velo_to_cam, self.R_rect_00, self.P_rect_02 = load_calib('./kitti_calib') self.sub_cloud = rospy.Subscriber('/kitti/velo/pointcloud', PointCloud2, self.cloud_callback) self.sub_image = rospy.Subscriber('/kitti/camera_color_left/image_rect', Image, self.image_callback) self.pub_result = rospy.Publisher('/projection/overlay', Image, queue_size=1) def cloud_callback(self, msg): self.latest_cloud = msg def image_callback(self, msg): self.latest_image = self.bridge.imgmsg_to_cv2(msg, 'bgr8') if self.latest_cloud is None: return self.process() def process(self): # 从PointCloud2转numpy数组 points = [] for p in point_cloud2.read_points(self.latest_cloud, field_names=('x', 'y', 'z'), skip_nans=True): points.append(p) if len(points) == 0: return pts = np.array(points).reshape(-1, 3) # 补齐次坐标 pts_velo = np.hstack((pts, np.ones((pts.shape[0], 1)))) pts_cam = (self.R_rect_00 @ (self.T_velo_to_cam @ pts_velo.T)).T mask = pts_cam[:, 2] > 0 pts_cam = pts_cam[mask] pts_img = (self.P_rect_02 @ pts_cam.T).T u = pts_img[:, 0] / pts_img[:, 2] v = pts_img[:, 1] / pts_img[:, 2] depth = pts_cam[:, 2] overlay = self.draw_projection(self.latest_image.copy(), u, v, depth) self.pub_result.publish(self.bridge.cv2_to_imgmsg(overlay, 'bgr8')) if __name__ == '__main__': try: PointCloudProjector() rospy.spin() except rospy.ROSInterruptException: pass

5.3 时间同步问题

如果你只用两个回调函数分别存储最新数据,在点云和图像发布频率不一致时,可能投影到当前的图上用的是半秒前的点云。这在动态场景里会导致明显的错位。

更好的做法是用ROS的时间同步器,让两个话题对齐到时间戳接近的消息对再处理:

from message_filters import Subscriber, ApproximateTimeSynchronizer sub_cloud = Subscriber('/kitti/velo/pointcloud', PointCloud2) sub_image = Subscriber('/kitti/camera_color_left/image_rect', Image) sync = ApproximateTimeSynchronizer([sub_cloud, sub_image], queue_size=10, slop=0.1) sync.registerCallback(self.sync_callback)

slop表示允许的最大时间差,一般0.05到0.1秒够用。如果你处理的是KITTI sync包,时间戳本身已经对齐,用这个方式效果更稳。

5.4 bag回放验证

如果你手头没有实车,可以用KITTI转好的bag文件回放验证。启动roscore后回放bag,然后运行上面的节点,再用rqt_image_view订阅/projection/overlay,就能看到实时叠加效果。这一步做完,整个ROS链路就算通了。

我建议先用bag回放验证一次,并故意把点云话题延迟一点,观察同步器是否有效。这个实验能帮你对“传感器同步”这件事建立直观感受,比单纯看代码更有价值。

6. 踩坑与优化:坐标系约定、畸变与性能

以下问题都是我实际调试中遇到过的,按出现频率排序,优先级从高到低。

6.1 坑一:把intensity当成齐次坐标的1

点云bin每行四个数,格式是(x, y, z, reflectance)。有些实现直接把整列丢进变换矩阵,结果投影出来的点全是乱的。正确做法是取前三列,然后自己拼接一列1。这个错误非常隐蔽,因为程序不会报错,只会出诡异的图像。

判断方法:如果投影结果看起来“有点对但又有大量散点”,大概率是这里出了问题。

6.2 坑二:忘记应用R_rect校正矩阵

KITTI的相机图像是校正后的,但点云变换到相机坐标系后如果直接投影,不经过R_rect校正,物体会出现横向偏移或者立体错位。严格按官方公式做,R_rect_00这一步不能省。看起来多一次矩阵乘法,实际就是为了抵消相机畸变校正带来的坐标系旋转。

6.3 坑三:图像去畸变还是不去畸变

KITTI的image_02是已经去畸变并校正过的图,所以数据里D_xx畸变系数基本用不上,直接投影没问题。如果你用自己的相机,图像没去过畸变,那一定要先用cv2.undistort()处理图像,或者把畸变模型加入投影公式,否则图像边缘的点会明显弯掉。

实操建议是:先用KITTI把整个流程跑通,再切换到自己传感器上,那时候再考虑畸变问题。一上来就处理自己的相机数据,问题面会太广,不好排查。

6.4 性能优化:别再写for循环

一个64线激光雷达一帧点云大约12万个点,如果逐点for循环投影再画圆,单帧处理可能要上百毫秒,根本谈不上实时。同样的工作用NumPy批量矩阵运算,一次@搞定变换,画点环节也要向量化:

valid_idx = np.where(valid)[0] if len(valid_idx) == 0: return image depth_norm = cv2.normalize(depth, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) colormap = cv2.applyColorMap(255 - depth_norm, cv2.COLORMAP_JET) for idx in valid_idx: cv2.circle(image, (int(u_arr[idx]), int(v_arr[idx])), 2, colormap[idx].tolist(), -1)

严格来说画点这一步仍然有循环,但投影核心部分已经向量化了。如果你要追求极致性能,可以用OpenCV的projectPoints或者直接画稀疏像素掩膜代替cv2.circle。在我的实际测试里,仅用NumPy向量化之后,12万点投影加画点大约耗时15-20毫秒,已经能满足10Hz以上的实时处理需求。

6.5 进阶优化:点云上色反馈到3D空间

投影不只能把点云画到图像上,还能反过来给点云上色。做法是把图像像素颜色按投影对应关系赋回给点云,然后以彩色点云的形式在RViz里显示。这样既能保留三维结构,又能看到纹理颜色,调试起来非常直观。

具体做法就是在得到u、v和depth之后,把有效的(u, v)坐标对应的像素颜色取出来,然后拼一个带RGB字段的PointCloud2发出去。字段拼接可以用sensor_msgs/PointCloud2的fields定义,也可以直接用pcl_ros的PointCloudXYZRGB。视觉效果比2D叠加更好,尤其在做语义分割和地图重建的时候。

自己写了一次之后,我建议你把“点云能否准确投影到图像”当作传感器标定是否正常的快速体检指标:雷达和相机的相对位姿只要偏了哪怕1度,投影边缘轮廓都会肉眼可见地错开。用这个方式检查设备,比看标定误差数值直观得多。

如果你手里有实车或者仿真环境,建议把这个节点做成常驻工具,每次开机跑一下投影叠加图,确认传感器状态。这个方法简单,但非常可靠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询