
简介面向自动驾驶与三维视觉开发者的一套Python代码资源解决激光雷达点云与相机图像像素对齐、生成融合彩色点云的问题。支持KITTI以及KITTI raw两种常见的标定文件组织方式既可将R_rect、P_rect、Tr等参数集中存储于单文件也支持相机与LiDAR2Cam参数分文件保存能够适配不同数据集的校准文件格式。压缩包共23个文件包含Python脚本、PNG示例图像、BIN点云数据、TXT校准参数、Markdown说明文档等整体大小8.77MB目录划分清晰便于按步骤运行和调试。已有3786人学习下载。除主流程代码外还提供可视化脚本与功能函数封装可输出点云投影到图像后的融合效果并支持鸟瞰图BEV和前视图FV展示同时附带示例输入与输出图片以及README说明方便快速验证算法和了解参数配置方式适合用于自动驾驶数据预处理、点云-图像融合实验或相关课程设计。1. 将点云投影到图像并生成带颜色的激光雷达点云一个能落地的 Python 实战流程做激光雷达感知时最直观的痛点是点云是“单色”的。无论是做目标检测还是语义分割看到的是密密麻麻的 XYZ 坐标缺少纹理信息。而相机图像恰好能补上这一步——把 3D 点云投影到 2D 图像上用像素颜色给点云上色就得到了一帧带 RGB 的彩色点云。这篇笔记讲的正是这个流程的完整 Python 实现从坐标系变换原理、内外参标定文件的读取到投影公式的代码落地再到绕开几个典型的坑。适合刚接触多传感器融合的开发者也适合已经在跑自动驾驶数据但想把视觉信息叠加到点云里的从业者。核心代码不依赖复杂框架用 NumPy 就能跑通能直接嵌进你自己的数据处理管线。2. 核心原理从激光雷达到像素的坐标变换为什么能对齐2.1 两个坐标系与一组外参先弄清坐标系关系激光雷达点云里的每个点都是 (x, y, z) 坐标这个坐标是相对于激光雷达自身坐标系原点的位置。而图像上的每个像素是 (u, v) 坐标这个坐标是相对于相机成像平面的位置。要把雷达点投影到图像像素位置必须经过一次坐标系变换先通过外参将雷达坐标系下的点转到相机坐标系再通过内参将相机坐标系下的点投影到像素坐标系。外参由旋转矩阵 R 和平移向量 T 组成描述的是雷达坐标系相对相机坐标系的刚体变换。这组参数通常来自标定过程比如使用 Autoware、标定板或者开源工具标定得到的。注意一个最容易出错的地方外参矩阵的 T 不是雷达原点到相机原点的距离绝对值而是“相机原点在雷达坐标系下的位置”经过旋转后的补偿值或者说 T 表示雷达坐标系原点在相机坐标系中的坐标。很多初学者在读取标定文件时把 T 的正负号搞反投影结果就会完全错位。用数学语言表达就是将雷达点 P_lidar 从雷达坐标系转换到相机坐标系P_cam R * P_lidar T。P_cam 得到的 (X_cam, Y_cam, Z_cam) 是点相对于相机光心的坐标Z_cam 是深度值。内参矩阵是在相机坐标系内完成投影的不涉及外部坐标变换。2.2 相机内参矩阵与投影公式从 3D 点到 2D 像素相机内参是相机出厂或标定得到的固定参数本质上是把“相机坐标系下的 3D 点”变成“图像上的像素坐标”。内参矩阵 K 形如[fx, 0, cx; 0, fy, cy; 0, 0, 1]fx 和 fy 是焦距相关的缩放系数单位是像素cx 和 cy 是光心在图像上的像素坐标。投影公式是纯几何关系从 P_cam 的 (X_cam, Y_cam, Z_cam) 得到 u 和 v关键是除以深度 Z_cam这是针孔相机模型的核心——距离越远同样的 3D 偏移在图像上占的像素越少。所以投影不可逆一个图像像素只能知道一条射线无法唯一确定 3D 点但一个 3D 点可以唯一映射到一个像素位置。投影公式如下u fx * X_cam / Z_cam cxv fy * Y_cam / Z_cam cy如果计算出来的 u、v 不在图像宽度、高度范围内说明这个雷达点不在相机视野内要排除掉。另外如果 Z_cam 0说明点在相机后方也不能投影。这个判断是所有后续代码的第一步过滤条件。下表整理了涉及到的关键参数和它们在代码中的变量名方便对照参数变量名建议说明旋转矩阵R (3x3 numpy array)外参标定文件中的旋转部分平移向量T (3x1 numpy array)外参标定文件中的平移部分焦距 xfx内参矩阵 K[0,0]焦距 yfy内参矩阵 K[1,1]光心坐标cx, cy内参矩阵 K[0,2], K[1,2]图像宽度width滤除视野外点图像高度height滤除视野外点3. 落地实现项目代码结构与关键逻辑解析3.1 数据准备拿到激光雷达点云、图像和标定参数这份资源里的代码包拿到手之后先看目录结构。通常包含一个核心脚本比如 project_lidar_to_camera.py、一组示例数据和一份标定参数文件JSON 或 YAML 格式。示例数据一般包含一帧激光雷达点云可以是 .bin 或 .pcd 格式和对应时间戳的一张相机图像.png 或 .jpg。标定参数文件里会有外参 R、T 和内参 K。读点云时要注意格式差异。如果是 KITTI 那样的 .bin 文件每个点是 4 个 floatx, y, z, intensity需要按 4 步长解析如果是 .pcd 文件就要用 open3d 或 pandas 读取。我一般会先写一个简单的读取函数把点云变成 (N, 3) 的 NumPy 数组再单独保存 intensity因为 intensity 在后续上色时可以当作额外通道用。这一步别省——很多项目里点云数据格式混杂统一成 NumPy 数组后后面的向量化投影运算才好写。标定参数的读取要格外注意文件的组织方式。KITTI 的 calib 文件里内参通常给了 3x3 矩阵外参给了 3x4 的变换矩阵包含 R 和 T 合在一起。而 Autoware 或者自己标定的 YAML 文件可能把 R 和 T 分开存。代码包里一般已经写好了读取函数我建议你把 R 和 T 打印出来核对一下维度R 是 3x3T 是 3x1这个检查能避免后续一大半的问题。3.2 主流程代码投影、着色与输出核心流程可以分成四步读取标定参数、读取点云和图像、坐标变换与投影、颜色赋值与保存。下面这段代码就是最精简的实现import numpy as np import cv2 def load_calib(calib_path): # 从JSON/YAML文件中读取内参和外参 # 返回 K (3x3), R (3x3), T (3x1) pass # 具体读取逻辑依赖文件格式资源代码中包含完整实现 def read_point_cloud(pcd_path): # 读取点云返回 Nx3 的坐标数组 # 注意这里省略了格式解析细节但核心是保证输出 (N,3) 的float32数组 pass def project_and_color(points, K, R, T, image): # points: (N, 3) 雷达坐标系下的点 # K: 内参矩阵, R/T: 外参 # image: BGR图像 # 返回: 彩色点云 (N, 3) 和对应的像素坐标 (N, 2) N points.shape[0] # 第一步雷达坐标 - 相机坐标 cam_points (R points.T).T T.ravel() # 第二步过滤掉相机后方的点 z cam_points[:, 2] valid_mask z 0 cam_points cam_points[valid_mask] # 第三步投影到像素坐标系 x_cam cam_points[:, 0] y_cam cam_points[:, 1] z_cam cam_points[:, 2] fx K[0, 0] fy K[1, 1] cx K[0, 2] cy K[1, 2] u fx * x_cam / z_cam cx v fy * y_cam / z_cam cy # 第四步过滤超出图像范围的投影点 h, w image.shape[:2] in_view_mask (u 0) (u w) (v 0) (v h) u_valid u[in_view_mask].astype(np.int32) v_valid v[in_view_mask].astype(np.int32) points_in_view points[valid_mask][in_view_mask] # 第五步从图像上取颜色 colors image[v_valid, u_valid] # 这里用整型索引直接取BGR颜色 return points_in_view, colors # 调用示例 k np.eye(3) r np.eye(3) t np.zeros((3, 1)) img cv2.imread(image.png) pts read_point_cloud(lidar.bin) colored_pts, pixel_coords project_and_color(pts, k, r, t, img)逻辑说明这段代码先做坐标变换再做深度过滤和视野过滤。投影公式和 2.2 节一致关键在于利用 NumPy 的向量化运算避免了逐点 for 循环N 个点的投影在毫秒级就能完成。颜色提取用image[v_valid, u_valid]时注意 NumPy 的行列顺序是 (v, u)即先用 y 索引再用 x 索引这个写错会让颜色完全乱掉。函数返回的points_in_view是原始雷达坐标系下能被该相机看到的点colors是对应的 BGR 颜色值。3.3 参数说明与扩展改哪些地方能适配你自己的传感器上面代码里R、T和K是从标定文件读出来的不同数据集格式差异很大。KITTI 的标定文件里外参是合并成 3x4 矩阵的需要用[:, :3]和[:, 3]拆开。如果是你自己标定的结果R 可能是四元数格式需要先转成旋转矩阵。代码包里一般已经处理好了这些格式差异但换成自己的数据时要重点检查三点第一R 和 T 的坐标顺序是否和你点云数据的坐标定义一致。比如有些雷达坐标 z 轴向上有些 y 轴向上需要统一。第二图像是否做过畸变校正。如果相机原图是带畸变的投影前要先对图像做 undistort否则边缘区域误差很大。第三时间同步问题。点云和图像如果不是同一时刻采集的物体运动会导致投影出现重影这个在静态场景下不明显但在车辆行驶中会特别严重。参数调整上如果你只需要给点云上色导出 PCD 文件可以直接跳过image[v_valid, u_valid]这一步改用cv2.cvtColor把 BGR 转成 RGB 再赋予点云。如果你需要全部点云包括不在视野内的点参与后续运算可以把in_view_mask过滤掉保留下所有点只在颜色数组缺失的地方填充 0。具体怎么取舍取决于你是要做可视化还是要做多模态模型输入。4. 调试与可视化分步验证你的映射结果4.1 投影结果可视化把点云画到图像上检查写完核心投影代码后第一件事不是着急保存彩色点云而是先做可视化验证。把投影得到的像素坐标画到原始图像上叠加成一张图肉眼看对齐效果。这个步骤能最快发现问题如果雷达点全部堆在图像中间说明 Z_cam 过滤条件有问题如果点云和图像里的物体边缘错位明显大概率是外参 R、T 错误。可视化代码很简单用 OpenCV 的circle画点即可# 在图像上画投影点颜色用红色 vis_img img.copy() for (u, v) in zip(u_valid, v_valid): cv2.circle(vis_img, (int(u), int(v)), 2, (0, 0, 255), -1) cv2.imwrite(projection_check.png, vis_img)注意前景物体和背景的叠合程度。我一般看两个区域一是建筑物的边缘轮廓雷达点应当沿边缘聚集成一条清晰的折线二是地面上的点应当均匀分布在路面上和车道线的走向一致。如果点云偏离到了图像里物体的外侧说明外参里 T 的方向或大小出了问题常见原因是标定文件里 T 的单位是米而代码里误当成毫米使用。4.2 从单帧到多帧测一测时间同步是否靠谱单帧可视化没问题后要跑连续多帧检查一致性。我自己遇到过的情况是单帧看起来完美对齐但车辆一转弯下一帧投影点全部飘了。问题出在时间戳对齐上——雷达是 10Hz相机是 30Hz如果用时间戳最近的一帧图像来投影车辆高速移动时就会产生约 30 毫秒的延迟在图像上表现为明显的错位。常见做法是线性插值。取雷达点云前后两帧图像按照雷达时间戳在图像时间戳区间内的比例对相机外参或图像做插值。更简单的方法是直接选用时间戳最接近的相机图像并且在后处理时把时间差超过 20 毫秒的帧丢弃。代码包里如果没有同步逻辑你可以自己加一个简单的最近邻匹配# 找出雷达帧对应的最近图像帧索引 nearest_idx np.argmin(np.abs(image_timestamps - lidar_timestamp))这一步虽然朴素但能消除大部分动态错位。如果跑的是 KITTI 或者 nuScenes 这类数据集数据本身已经做了同步但换成自采数据时必须自己处理。从这往后投影质量就不仅仅是数学问题了还有传感器硬件层面的同步误差。5. 避坑指南外参标定、像素越界与颜色错乱现象 1投影点全部堆在图像中心或完全不在图像上原因Z_cam出现负值说明点云在相机后方却被当成了有效点或者R、T的方向写反了。另一个常见原因是内参矩阵 K 用了归一化坐标系的 fx、fy而不是像素单位的焦距。解决先打印几个点的cam_points和z值确认大部分点是正的然后单独测试一个已知位置的雷达点手动计算投影坐标比对代码输出。如果所有点都在图像外先检查R的转置和逆——外参旋转矩阵如果是从“相机到雷达”的需要转置成“雷达到相机”。现象 2投影后的彩色点云颜色错乱物体的颜色边缘有“鬼影”原因图像坐标索引写反了。OpenCV 的图像数组是(height, width)即(v, u)但不少人在image[u, v]上取值。还有一个原因是取了“最近邻”但没有做交界处处理滤波法也容易踩这个坑用cv2.remap时映射表必须是 float32 网格传成 int 数组会让颜色变得斑驳。解决用image[v_valid, u_valid]显式区分行、列如果要做像素插值用cv2.remap(src, map_x, map_y, cv2.INTER_LINEAR)并把 map_x、map_y 显式构造成(h, w)形状的 float32。我一般先用单点手动验证取点云中的第 100 个点用公式算出 (u, v)手动读取图像像素值看和代码输出的颜色一致不一致。现象 3投影点“分层”或“少一大块”原因点云文件的坐标范围很大但相机视野本身有限。很多点本身就在图像外被过滤掉了这是正常的。但“分层”严重的话往往是时间同步没做好物体在前后帧之间有位移投影结果和图像不完全重合。也可能是外参标定误差在边缘放大——离光心越远的区域同样的角偏差在像素上偏移越多。解决确认过滤逻辑只删除了视野外点而不是把所有 z 小于某个阈值的点都删了。检查外参标定的重投影误差是否在 1 像素以内如果标定板拍得不够多重新标定如果只是粗略对齐可以手动微调 T 的 z 分量沿相机光轴方向这个方向对投影大小影响最明显。现象 4整帧点云的颜色偏暗或偏亮和原图不符原因图像是 8 位无符号整型颜色通道顺序是 BGR而点云库比如 Open3D保存 PCD 时默认 RGB 顺序。直接np.asarray(image[v_valid, u_valid])得到的 BGR 值赋给 Open3D 的 PointCloud 后红色和蓝色会互换视觉上看起来整体色调偏蓝或偏红。解决保存彩色点云前做一次通道转换colors cv2.cvtColor(colors.reshape(1, -1, 3), cv2.COLOR_BGR2RGB).reshape(-1, 3)。另外颜色数组必须是 float 类型且归一化到 [0,1] 区间Open3D 的colors属性不接受 0-255 的整数会在渲染时产生疑问效果。6. 进阶技巧反向投影做图像上色与多帧累积投影方向的流程跑通后可以做一个反向操作把图像颜色“贴”回点云之后再用彩色点云反过来给图像补信息比如深度图生成或者遮挡检测。这里有个非常实用的技巧用投影得到的points_in_view坐标和深度值可以构建一个稀疏深度图。把投影后有有效深度的像素填上Z_cam然后做插值就得到了和图像对齐的深度图。这个方法对后续的目标测距或语义分割辅助很有价值。核心代码思路是准备一个和图像同尺寸的数组把有效的(u, v, z)填进去剩下的空洞用cv2.inpaint或简单的线性插值补上depth_map np.zeros((h, w), dtypenp.float32) depth_map[v_valid, u_valid] z_cam_valid # 对空洞做邻域填充 depth_map cv2.inpaint(depth_map, mask, 3, cv2.INPAINT_TELEA)另一个进阶方向是多帧累积。单帧点云能看到的角度有限车辆前进几米后之前被遮挡的区域暴露出来了。把连续几帧的彩色点云拼接在一起可以生成一个局部的稠密彩色地图。操作上就是每帧输出一个彩色点云然后用 ICP迭代最近点或直接用里程计位姿做拼接。如果手头没有里程计可以先用相邻帧的雷达特征做粗略配准效果虽然不如视觉里程计但静态场景够用。最后说一个我自己遇到过的教训投影这件事“看着没啥问题”和“真没问题”完全是两回事。从那以后我每次跑完投影都会强制走一遍“单点手动验算 多帧错位检查 颜色通道核对”三步流程确认无误才把点云送进下游。这个小习惯帮我省下了无数次把错误数据灌进模型再回头查数据的尴尬。希望这篇笔记能帮你在点云上色的路上少走几个弯路。本文还有配套的精品资源点击获取