
1. 从零开始为什么我们需要点云到图像的转换大家好我是老张在自动驾驶和三维感知领域摸爬滚打了十来年。今天想和大家聊聊一个非常具体但在多模态感知开发中绕不开的话题如何把激光雷达LiDAR扫描到的三维点云精准地“画”到相机的二维图像上这听起来像是个纯理论的坐标变换问题但在实际项目中它可是个“拦路虎”。我见过不少刚入行的朋友拿到像NuScenes这样包含激光雷达和相机数据的大规模数据集时第一反应是兴奋第二反应可能就是迷茫。数据都有了怎么把它们“对齐”起来让三维世界和二维图像对上号呢想象一下这个场景你的自动驾驶系统通过激光雷达“看到”前方50米处有一个障碍物一个三维的点云簇同时前向摄像头也拍到了一张图片。你如何确认激光雷达“看到”的那个点簇就是图片里那辆白色的轿车这就是传感器融合最基础也最关键的一步——时空同步与坐标对齐。只有完成了这一步你才能用图像丰富的纹理信息去辅助理解点云的几何结构或者用点云精确的距离信息去修正图像检测框的深度实现112的效果。NuScenes数据集为我们提供了完美的“练兵场”。它包含了丰富的传感器数据1个激光雷达6个摄像头5个毫米波雷达以及精确的标定信息。但官方SDK提供的更多是数据读取和可视化工具关于如何从底层实现坐标转换往往需要我们自己动手。网上资料虽然多但要么过于理论要么代码片段零散新手很难串起来。所以这篇文章我就结合自己踩过的坑手把手带你走一遍从理解坐标系、解析标定数据到用代码一步步实现点云投影的全过程。我们的目标很明确写一个能跑通、能出结果、并且你能完全看懂每一行在干什么的实战脚本。我们不求一步登天但求每一步都走得踏实。2. 庖丁解牛彻底搞懂NuScenes的三大坐标系在写代码之前我们必须把脑子里的“地图”画清楚。坐标转换之所以让人头疼往往是因为坐标系没理清。在NuScenes以及绝大多数自动驾驶数据集中主要涉及三个层级的坐标系我习惯把它们想象成一套“俄罗斯套娃”。2.1 全局坐标系世界的地图全局坐标系你可以把它想象成一张固定不变的世界地图。在NuScenes中这个坐标系的原点通常是数据采集车在某个初始时刻比如场景开始记录的第一秒的位置。整个场景中所有物体、车辆的位置最终都会用这个坐标系下的(x, y, z)坐标来描述。它的好处是提供了一个统一的“锚点”让我们可以比较不同时刻、不同位置物体的关系。2.2 自车坐标系车辆的“随身空间”自车坐标系也叫ego坐标系这是以车辆自身为中心建立的坐标系。原点通常在车辆的后轴中心、或者某个预设的传感器安装中心。X轴指向车头方向Y轴指向左侧Z轴指向上方。这个坐标系是“跟着车走的”车一动这个坐标系的原点就在全局坐标系里移动。所有传感器的原始数据最初都是在这个坐标系下表达的。比如激光雷达报告“前方10米左偏2米高度1.5米处有一个点”这个“前方、左偏”就是相对于自车坐标系而言的。2.3 传感器坐标系每个传感器的“独有视角”这是最内层的“套娃”。每个传感器激光雷达、每个摄像头都有自己独立的坐标系。原点在传感器的光学中心或发射中心。对于摄像头这通常是镜头的光心对于激光雷达则是其旋转中心。传感器标定的核心就是告诉我们这个传感器的坐标系相对于自车坐标系ego经历了怎样的旋转和平移。搞清楚了这三层关系我们转换的路径就清晰了。我们的目标是把一个在激光雷达坐标系下的三维点P_lidar转换到某个摄像头坐标系下的二维像素坐标(u, v)。这个过程需要分几步走P_lidar-P_ego(通过激光雷达的外参标定)P_ego-P_global(通过采集该帧激光雷达数据时车辆的位姿)P_global-P_ego_cam(通过采集该帧图像数据时车辆的位姿。注意这个ego位姿和第一步的可能不同因为激光雷达和相机数据的时间戳有微小差异)P_ego_cam-P_camera(通过摄像头的外参标定)P_camera-(u, v)(通过摄像头的内参矩阵)这个过程初看很复杂但核心思想就是通过一连串的矩阵乘法把点从一个坐标系“搬运”到另一个坐标系。下面我们就进入实战环节看看怎么用代码获取这些关键的“搬运工具”——变换矩阵。3. 实战第一步搭建环境与读取数据理论说再多不如跑行代码。我们先来把舞台搭好。3.1 安装必备工具包这里没什么黑科技主要就是NuScenes官方的开发工具包nuscenes-devkit以及一个处理旋转的利器pyquaternion。我强烈建议你创建一个新的虚拟环境来做这件事避免包版本冲突。pip install nuscenes-devkit pyquaternion opencv-python numpyopencv-python和numpy是标配用来处理图像和矩阵运算。安装完成后我们可以先导入它们并准备好数据集路径。from nuscenes.nuscenes import NuScenes from nuscenes.utils.data_classes import Box from pyquaternion import Quaternion import numpy as np import cv2 import os # 设置numpy打印格式方便调试时查看矩阵 np.set_printoptions(precision3, suppressTrue) # 1. 指定数据集版本和路径 # 如果你用的是mini集version就是“mini” version mini # 这里替换成你本地存放NuScenes数据集的根目录路径 dataroot /path/to/your/nuscenes/data # 例如dataroot /home/user/data/nuscenes # 2. 实例化NuScenes对象这是操作数据的总入口 nusc NuScenes(versionv1.0-{}.format(version), datarootdataroot, verboseTrue)运行nusc NuScenes(...)时如果verboseTrue你会看到终端打印出加载的各种数据表比如有多少个sample样本、sample_data传感器数据帧、scene场景等。看到这些信息就说明你的数据路径设置正确SDK成功加载了数据。3.2 理解数据组织结构Sample是关键NuScenes的数据是按sample组织的。一个sample代表了同一时刻所有传感器1个LiDAR6个Cameras5个Radars采集到的一帧数据以及这一时刻的3D物体标注anns。我们来取出第一个sample看看# 获取第一个样本场景的第一帧数据 sample nusc.sample[0] print(sample.keys()) # 输出dict_keys([token, timestamp, prev, next, scene_token, data, anns])这个sample是一个字典其中最重要的两个键是data: 里面存储了各个传感器在这一帧对应的数据令牌token。比如sample[‘data’][‘LIDAR_TOP’]就是顶部激光雷达这一帧数据的token通过这个token我们可以找到具体的点云文件。anns: 这是一个列表里面是这一帧所有3D标注框的token。每个token对应一个具体的物体比如车辆、行人。你可以用nusc.list_sample(sample[‘token’])来直观地查看这个sample里包含了哪些传感器数据和标注这个命令会打印出非常清晰的信息。4. 核心武器如何获取并理解变换矩阵坐标转换的本质是矩阵乘法。我们需要四种矩阵传感器到自车的变换矩阵外参自车到全局的变换矩阵车辆位姿全局到自车的逆变换矩阵另一个时刻的车辆位姿的逆自车到传感器的逆变换矩阵相机外参的逆相机内参矩阵4.1 编写通用的矩阵构造函数首先我们写一个 helper 函数它能根据标定数据中的rotation四元数和translation平移向量构造一个4x4的齐次变换矩阵。这个函数是后续所有操作的基础。def get_matrix4x4(rotation, translation, inverseFalse): 根据旋转四元数和平移向量构造一个4x4的变换矩阵。 参数: rotation: 四元数列表 [w, x, y, z] 或 np.array translation: 平移向量列表 [x, y, z] 或 np.array inverse: 如果为True返回该矩阵的逆矩阵。 返回: T: 4x4的齐次变换矩阵。 # 初始化一个单位矩阵 T np.eye(4) # 将四元数转换为3x3旋转矩阵并填入T的前3行3列 T[:3, :3] Quaternion(rotation).rotation_matrix # 将平移向量填入T的前3行第4列 T[:3, 3] translation # 如果需要逆矩阵则对T求逆 if inverse: T np.linalg.inv(T) return T为什么是4x4矩阵因为三维空间的旋转3x3矩阵和平移3x1向量可以统一用一个4x4的齐次变换矩阵来表示这样就能用一次矩阵乘法同时完成旋转和平移操作非常方便。点坐标也需要增加一个维度变成[x, y, z, 1]。4.2 获取激光雷达的变换矩阵现在我们来获取将激光雷达点云转换到全局坐标系所需的两把“钥匙”。# 1. 获取激光雷达这一帧的数据信息 lidar_token sample[data][LIDAR_TOP] lidar_sample_data nusc.get(sample_data, lidar_token) # 2. 获取激光雷达的标定参数外参描述激光雷达坐标系与自车坐标系的关系 lidar_calibrated nusc.get(calibrated_sensor, lidar_sample_data[calibrated_sensor_token]) # 构造 lidar - ego 的变换矩阵 lidar_to_ego get_matrix4x4(lidar_calibrated[rotation], lidar_calibrated[translation]) # 3. 获取采集这帧激光雷达数据时自车的位姿ego pose lidar_ego_pose nusc.get(ego_pose, lidar_sample_data[ego_pose_token]) # 构造 ego - global 的变换矩阵 ego_to_global get_matrix4x4(lidar_ego_pose[rotation], lidar_ego_pose[translation]) # 4. 那么从 lidar 直接到 global 的变换就是连续相乘 # 注意矩阵乘法的顺序点向量在右侧变换从左到右依次应用 lidar_to_global ego_to_global lidar_to_ego # 等价于 ego_to_global * lidar_to_ego到这里我们已经拿到了将任意一个激光雷达坐标系下的点P_lidar转换到全局坐标系P_global的“总开关”lidar_to_global。我们可以先加载一帧点云数据验证一下。# 加载点云数据NuScenes的点云是.bin文件每行5个数x, y, z, intensity反射强度, ring_index激光线束id lidar_file_path os.path.join(nusc.dataroot, lidar_sample_data[filename]) pointcloud np.fromfile(lidar_file_path, dtypenp.float32).reshape(-1, 5) # 形状为 (N, 5) # 取前3个坐标 (x, y, z)并补充齐次坐标的第四维 1 points_lidar np.concatenate([pointcloud[:, :3], np.ones((pointcloud.shape[0], 1))], axis1) # 形状 (N, 4) # 转换到全局坐标系P_global P_lidar * (lidar_to_global)^T # 因为我们的点向量是行向量 (1x4)而变换矩阵是4x4所以用点云矩阵右乘变换矩阵的转置 points_global points_lidar lidar_to_global.T # 形状 (N, 4)4.3 获取相机的变换矩阵与内参接下来处理相机端。NuScenes有6个相机我们需要对每一个都进行处理。这里以CAM_FRONT前向相机为例。# 定义6个相机的通道名 cameras [CAM_FRONT_LEFT, CAM_FRONT, CAM_FRONT_RIGHT, CAM_BACK_LEFT, CAM_BACK, CAM_BACK_RIGHT] # 我们选择前向相机 camera_channel CAM_FRONT camera_token sample[data][camera_channel] camera_sample_data nusc.get(sample_data, camera_token) # 1. 获取相机的标定参数外参和内参 camera_calibrated nusc.get(calibrated_sensor, camera_sample_data[calibrated_sensor_token]) # 构造 camera - ego 的变换矩阵然后求逆得到 ego - camera camera_to_ego get_matrix4x4(camera_calibrated[rotation], camera_calibrated[translation]) ego_to_camera np.linalg.inv(camera_to_ego) # 或者直接在get_matrix4x4中设置inverseTrue # 2. 获取采集这帧图像数据时自车的位姿 camera_ego_pose nusc.get(ego_pose, camera_sample_data[ego_pose_token]) # 构造 ego - global 的变换矩阵然后求逆得到 global - ego ego_to_global_cam get_matrix4x4(camera_ego_pose[rotation], camera_ego_pose[translation]) global_to_ego np.linalg.inv(ego_to_global_cam) # 3. 获取相机内参矩阵并扩展为4x4齐次形式以便后续统一相乘 camera_intrinsic np.eye(4) camera_intrinsic[:3, :3] camera_calibrated[camera_intrinsic] # 内参是3x3矩阵 # 4. 组合出从全局坐标系到图像像素坐标系的完整变换矩阵 # 变换链Global - Ego (at camera time) - Camera - Image (Pixel) global_to_image camera_intrinsic ego_to_camera global_to_ego这里有个非常重要的细节请注意lidar_ego_pose和camera_ego_pose很可能是两个不同的token即使它们属于同一个sample。这是因为激光雷达和相机硬件触发时间有微小的毫秒级差异NuScenes用不同的ego_pose来精确描述各自采集瞬间车辆的位置和姿态。这是实现高精度时空同步的关键千万不能直接用激光雷达的ego_pose去代替相机的。5. 终极目标将点云和3D框投影到图像上万事俱备只欠投影。现在我们有了points_global: 所有激光雷达点在全局坐标系下的坐标。global_to_image: 针对当前相机从全局坐标到图像像素坐标的变换矩阵。5.1 投影点云投影过程就是一次矩阵乘法加上透视除法除以深度z。# 将全局坐标系下的点转换到相机图像坐标系 # points_global 形状为 (N, 4) points_on_image points_global global_to_image.T # 形状 (N, 4) # 透视除法将齐次坐标 [x, y, z, w] 转换为 [x/w, y/w, z/w, 1]这里w就是深度z # 我们关心的是前两维 (u, v) points_on_image[:, :2] / points_on_image[:, [2]] # 除以第三列深度z # 现在 points_on_image[:, 0] 是像素u坐标 points_on_image[:, 1] 是像素v坐标 # 过滤掉深度值小于等于0的点这些点在相机后面不可见 valid_indices points_on_image[:, 2] 0 points_2d points_on_image[valid_indices, :2].astype(np.int32) # 取前两维并转换为整数5.2 投影3D标注框除了点云我们还想把数据集中提供的3D物体标注框也投影到图像上看看检测框是否对齐。这比投影点稍微复杂一点因为框有8个角点。# 读取相机图像 image_file os.path.join(nusc.dataroot, camera_sample_data[filename]) image cv2.imread(image_file) # 遍历这个sample中的所有标注 for ann_token in sample[anns]: annotation nusc.get(sample_annotation, ann_token) # 使用NuScenes提供的Box类方便地计算3D框的8个角点 # Box参数中心点(translation), 尺寸(size), 朝向(rotation) box Box(annotation[translation], annotation[size], Quaternion(annotation[rotation])) # box.corners() 返回一个3x8的矩阵每一列是一个角点的三维坐标 corners_3d box.corners() # 形状 (3, 8) # 将角点转换为齐次坐标 (4, 8) corners_3d_homo np.concatenate([corners_3d, np.ones((1, corners_3d.shape[1]))], axis0) # 投影到图像平面 corners_on_image global_to_image corners_3d_homo # 形状 (4, 8) # 透视除法 corners_on_image[:2, :] / corners_on_image[2, :] # 转置并取整得到8个角点的2D像素坐标 corners_2d corners_on_image[:3, :].T.astype(np.int32) # 形状 (8, 3), 第三列是深度z # 绘制3D框的12条棱边 # 定义框的12条边的连接顺序 lines [(0,1), (1,2), (2,3), (3,0), # 底面四边形 (4,5), (5,6), (6,7), (7,4), # 顶面四边形 (0,4), (1,5), (2,6), (3,7)] # 连接底面和顶面的竖棱 for start_idx, end_idx in lines: pt1 corners_2d[start_idx] pt2 corners_2d[end_idx] # 检查两个端点是否都在相机前方深度z0 if pt1[2] 0 or pt2[2] 0: continue # 如果有一个点在相机后面这条边不绘制 cv2.line(image, (pt1[0], pt1[1]), (pt2[0], pt2[1]), (0, 255, 0), thickness2) # 将投影后的点云也画到图像上用蓝色小圆点 for (u, v) in points_2d[:1000]: # 为了可视化清晰这里只画前1000个点 # 确保点在图像范围内 if 0 u image.shape[1] and 0 v image.shape[0]: cv2.circle(image, (u, v), radius1, color(255, 0, 0), thickness-1) # 保存或显示结果 output_path fprojection_{camera_channel}.jpg cv2.imwrite(output_path, image) print(f结果已保存至: {output_path}) # 如果想直接显示可以使用 cv2.imshow(Projection, image); cv2.waitKey(0)运行这段代码你就能得到一张叠加了激光雷达点云蓝色点和3D标注框绿色线框的相机图像。如果投影正确你会发现点云密集地落在物体表面而3D框也紧紧地包裹住了图像中的车辆、行人等目标。这是我调试时最有成就感的时刻。6. 避坑指南与性能优化第一次跑通固然开心但在实际项目应用中我们还会遇到一些“坑”。这里分享几个我踩过的以及对应的解决方案。6.1 时间戳同步与位姿插值前面提到激光雷达和相机的ego_pose可能不同。在绝大多数情况下使用各自精确的ego_pose已经能获得很好的对齐效果。但是如果你在做极其精细的时序分析或者传感器间硬件同步有已知的固定延迟你可能需要更精确的插值。NuScenes的ego_pose表是按时间戳排序的你可以根据传感器数据的时间戳在两个最近的ego_pose之间进行线性插值对平移和球面线性插值对旋转四元数来估计传感器采集瞬间更精确的车辆位姿。不过对于初期的融合开发直接使用提供的ego_pose通常足够了。6.2 处理大规模点云的性能问题一帧激光雷达点云动辄几万甚至十几万个点。如果你要对所有6个相机都做投影并且处理连续的视频流纯Python循环可能会成为瓶颈。这里有几个优化思路向量化操作我们上面的代码已经大量使用了NumPy的矩阵运算这是最重要的优化。确保所有点云的变换都是通过一次大型矩阵乘法完成的避免对每个点使用for循环。并行化对多个相机的投影操作是相互独立的可以使用Python的multiprocessing或concurrent.futures库进行并行处理。深度过滤与视锥体裁剪在投影前可以先在3D空间进行粗略过滤。比如只保留相机前方一定距离内如0.1米到100米的点或者利用相机的视锥体frustum在3D空间剔除肯定不可见的点。这能显著减少需要投影和后续处理的点数。使用GPU加速如果使用PyTorch或TensorFlow可以将点云数据和变换矩阵都放到GPU上利用其强大的并行计算能力进行批量变换。6.3 外参标定与传感器时空对齐的再思考我们使用的变换矩阵都来自于数据集提供的标定数据。这些标定数据是在数据采集前通过精密仪器测量得到的。但在真实世界中车辆行驶的震动、温度变化可能导致传感器之间微小的相对位移外参变化。对于追求极致性能的系统需要考虑在线标定或外参自适应技术。不过在算法研发初期使用NuScenes这样的数据集时我们可以认为标定是固定且准确的这为我们提供了一个稳定的基准。6.4 结果可视化与调试技巧当投影结果看起来不太对时比如点云飘在空中或者3D框错位不要慌按步骤排查检查矩阵打印出每一步的变换矩阵特别是lidar_to_ego、ego_to_global、global_to_ego(camera)、ego_to_camera。观察平移向量的大小是否合理ego到global的平移可能很大几十上百米传感器到ego的平移通常只有一两米。分步投影不要一次性从lidar到image。可以先把点云转到global用nusc.render_pointcloud_in_image之类的官方函数看看在global视图下是否正确。再单独测试从global到image的投影。检查内参确认相机内参矩阵的格式是否正确。NuScenes的内参矩阵K通常是[[fx, 0, cx], [ 0, fy, cy], [ 0, 0, 1]]其中(cx, cy)是光心主点通常在图像中心附近。验证单个点手动计算一个你知道应该在图像某个位置的3D点比如地面上的一个点看看投影后的2D坐标是否符合预期。7. 从投影到应用开启多模态感知的大门成功实现点云到图像的投影远不止是画一张漂亮的图。它是打通视觉和激光雷达两个感知世界的桥梁是许多高级应用的第一步。应用一生成深度图或语义点云。你可以将激光雷达点的深度信息根据投影关系“贴”到图像的每一个像素上生成一个稀疏的深度图。更进一步你可以利用图像语义分割的结果为每一个投影到图像上的激光雷达点打上语义标签如“车辆”、“行人”、“道路”得到带有语义信息的3D点云这对于基于点云的语义分割任务是非常宝贵的监督信号。应用二增强图像目标检测。在2D图像检测中判断目标的距离深度一直是个难题。现在你可以将投影到某个2D检测框内的所有激光雷达点收集起来用这些点的三维位置来估算该目标的精确距离、甚至大小和朝向极大地提升检测结果的实用性。应用三多传感器融合检测。这是目前自动驾驶感知的主流方向。你可以运行一个图像检测器和一个点云检测器然后通过我们刚才建立的投影关系将2D检测框和3D检测框进行关联、匹配、融合最终输出更稳定、更准确的3D检测结果。像PointPainting、PointAugmenting等经典方法都依赖于这种精确的坐标转换。应用四数据增强。在图像数据增强时如裁剪、旋转你可以同步地对关联的激光雷达点云进行变换保持多模态数据的一致性从而生成更多样化的训练数据。我自己的项目里第一次跑通这个投影流程后花了整整一周时间反复验证和调试确保每个像素的误差都在可接受范围内。这个过程虽然繁琐但价值巨大。它让你对传感器之间的几何关系有了肌肉记忆般的理解以后再看到融合相关的论文那些坐标转换公式就不再是黑盒了。希望这份详细的指南能帮你跨过这关键的第一步少走些弯路。剩下的就是发挥你的创意去构建更强大的感知系统了。