☰
激光雷达点云投影到相机图像的完整实现与避坑指南
2026/10/12 9:02:53 网站建设 项目流程

做这个系列做到第四篇,前面已经啃完传感器标定的原理部分,这篇就把激光雷达3D点云映射到相机图像这件事彻底跑通。说白了,就是让雷达"看到"的世界,精准地"画"到照片上——每个3D点落在图像的哪个像素位置,还得处理遮挡、裁剪、上色这些实际工程问题。适合正在做多传感融合相关的项目、作业或demo的同学,尤其是手头有激光雷达和相机数据、但不知道怎么把两者对齐的人。这篇是下篇,侧重代码实现和踩坑记录,配合上篇的数学原理,应该能让你从"公式看得懂"到"代码跑得通"。

1. 投影链路整体拆解:3D点是怎么变成2D像素的

1.1 本质:两个传感器各说各话,需要一个"翻译官"

激光雷达输出的是三维点云,每个点有xyz坐标,它描述的是传感器周围世界的几何结构;相机输出的是二维图像,每个像素有RGB值,它描述的是光线的颜色和强度。要让这两种数据对齐,就得把三维点云的坐标变换到相机的成像平面上。

这个过程的本质,是把一个在雷达坐标系下描述的3D点,转换到相机坐标系下描述,再按照相机的成像模型投影到像素坐标系。想想两个人在不同位置看同一栋房子:你说"它在我的东北方向30米",我说"它在我的正前方20米"。两个人都没错,但要用一个统一的坐标去讨论,就得知道两个人的相对位置和朝向——这就是外参矩阵干的事。

1.2 链路里的四个关键环节

一次完整的投影,从上到下依次要经过四个环节:

  1. 点云数据读取:从雷达采集文件里取出每个点的xyz坐标,通常还会带反射强度或时间戳,但投影时只需要坐标。
  2. 外参变换:利用标定好的旋转矩阵R和平移向量t,把点从雷达坐标系移动到相机坐标系。这一步解决的是"两个传感器位置和朝向不同"的问题。
  3. 内参投影:利用相机的焦距、主点和畸变参数,把相机坐标系下的3D点投影到2D像素坐标。这一步解决的是"三维世界如何成像到二维传感器"的问题。
  4. 像素筛选与渲染:投影出来的2D点不一定都在图像范围内,有的跑到图像左边外面去了,有的跑到上边外面去了,得裁剪;如果多个3D点投影到同一个像素附近,还要决定让谁显示。

1.3 一次投影的完整数据流

拿一个具体的例子走一遍:假设有一个3D点P,它在雷达坐标系下的坐标是(10, 2, -1),单位米。这里x是前方,y是左侧,z是上方(这是KITTI风格点云最常见的约定)。

第一步,给P补一个齐次分量变成4维向量(10, 2, -1, 1),然后左乘外参矩阵Tr_velo_to_cam。这个矩阵是3x4的,由旋转矩阵(3x3)和平移向量(3x1)拼成。结果得到P在相机坐标系下的坐标,比如(9.8, 1.5, 5.2)——含义是:这个点在相机前方9.8米、右侧1.5米、下方5.2米处(相机坐标系y轴向下)。

第二步,对相机坐标系下的点再做一次矫正旋转R0_rect(这是双目/多目相机去畸变对齐用的一个3x3矩阵),然后补上齐次分量,左乘内参投影矩阵P2。P2是一个3x4矩阵,前三列是内参矩阵K,最后一列是平移(通常为0)。得到的结果是(2345.6, 867.3, 5.2)。

第三步,除以z分量完成透视除法:u = 2345.6 / 5.2 ≈ 451,v = 867.3 / 5.2 ≈ 167。这就是像素坐标。

这个流程看起来简单,但换上真实数据后,坑就一个接一个地冒出来:坐标系方向搞反、矩阵维度不对、点云里有无效点、投影到图像外的点没过滤、远处点把近处点遮挡了等等。下面把每个环节的细节拆开讲。

2. 坐标系约定与内外参:最容易出错的工程细节

2.1 激光雷达坐标系约定

大多数自动驾驶数据集里的雷达点云遵循这个约定:x轴指向正前方,y轴指向左侧,z轴指向上方。注意,这是一个"右手坐标系":x叉乘y得到z(右手定则,x向前、y向左、z向上的确满足右手系)。不同雷达厂商可能有不同定义,有的用x向右、y向前,有的用z向下,所以拿到点云第一件事就是确认坐标系定义,不要假设。

怎么确认?直观方法:把点云画出来,看看车头方向对应哪个轴;更严谨的方法是看数据集的文档或calib文件说明。如果方向搞错了,后面投影结果一定是扭曲或镜像的。

2.2 相机坐标系约定

相机坐标系是另一个约定:z轴指向相机前方(拍摄方向),x轴指向右侧,y轴指向下方。这和图像坐标系的u向右、v向下是对应的。也就是说,一个在相机坐标系下坐标为(X, Y, Z)的点,其投影像素坐标大致是u = fx * X / Z + cx,v = fy * Y / Z + cy。

关键点来了:雷达坐标系的"y轴向左",到了相机坐标系变成了"y轴向下",z轴从"向上"变成了"向前"。所以从雷达到相机的变换,本质上是一个轴的重新排列加旋转。如果你在写旋转矩阵时把顺序搞乱,又或者平移向量符号写反,结果轻则投影点错位,重则整体镜像。

2.3 外参矩阵:R和t到底怎么填

外参矩阵通常写成:

Tr_velo_to_cam = [R | t]

其中R是3x3旋转矩阵,t是3x1平移向量。这个矩阵的数学含义是:将雷达坐标系下的点p_velo变换到相机坐标系下:

p_cam = R * p_velo + t

t的含义是"雷达坐标系原点在相机坐标系下的位置",而不是"相机在雷达坐标系下的位置"。这一点特别容易混。如果你的标定结果给出的是后者,需要取相反方向变换,即t_cam_from_velo = -R * t_velo_from_cam。实践中,多数标定文件会直接给Tr_velo_to_cam,名字里就写清楚了方向,直接用就行。

还有一个容易被忽略的点:R和t不能反过来用。有人图省事,把Tr_velo_to_cam的R取了转置来当逆向变换用,这是对的——旋转矩阵的逆等于转置。但如果把平移向量也直接取负就错了,正确公式是:

p_velo = R.T * (p_cam - t)

因为:

p_cam = R * p_velo + t => p_velo = R.T * (p_cam - t)

这个细节在写反向投影(从相机到雷达)时很关键,虽然正向投影用不到,但调试时经常需要来回验算。

2.4 内参矩阵与畸变模型

内参矩阵K长这样:

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

fx和fy是焦距(单位是像素),cx和cy是主点(图像中心)的像素坐标。投影公式就是前面提到的:

u = fx * X / Z + cx v = fy * Y / Z + cy

这里的X、Y、Z是相机坐标系下的坐标。这个公式是针孔相机模型,不考虑畸变。真实相机有镜头畸变,包括径向畸变(k1, k2, k3)和切向畸变(p1, p2),一般在做相机标定时会一并求出。但注意,很多自动驾驶公开数据集提供的图像已经做过畸变校正,calib文件里也直接给出P2投影矩阵,这种情况下就不需要再手动处理畸变,直接用P2投就好。如果你用的是原始图像或自己标定的相机,则需要在投影后做畸变修正,或者用OpenCV的cv2.projectPoints一步到位。

这里有一个常见的困惑:P2到底是什么?P2是3x4的投影矩阵,它本身包含了内参K和一个外参旋转平移(通常是矫正后的坐标系到左相机图像坐标系的变换)。在KITTI格式的calib文件里,P2 = K * [R_rect | t_rect],其中R_rect是矫正旋转矩阵R0_rect扩展成4x4的样子。实际代码可以先做Tr_velo_to_cam,再做R0_rect,最后用P2直接投,也可以把Tr和R0_rect乘在一起,整体作为外参,再左乘P2。

3. 完整实操:从点云文件到彩色投影图

3.1 数据准备与文件读取

实操阶段我用一套公开的自动驾驶场景数据举例。文件结构通常是:一个点云bin文件(float32数组,每4个数一组:x, y, z, intensity),一个calib.txt标定文件(包含若干行矩阵),一张相机图像。

calib文件里的关键字段:

字段维度含义
P0/P1/P2/P33x4不同相机的投影矩阵
R0_rect3x3矫正旋转矩阵
Tr_velo_to_cam3x4雷达到相机的外参

读取calib的代码思路:逐行解析,把每行的矩阵reshape成对应维度。注意P2是3x4,直接读成3行4列就行;R0_rect是3x3,但使用时要补成4x4(左上角放R0,右下角放1)。

3.2 核心投影代码:纯numpy,不依赖OpenCV也能跑

下面这段是投影的骨架,每一步都写了注释。建议你自己敲一遍,不要复制粘贴完事。

import numpy as np import cv2 def load_calib(filepath): """ 解析calib.txt,返回各矩阵。假设格式为 'P2: 数值...' """ calib = {} with open(filepath, 'r') as f: for line in f: if ':' not in line: continue key, values = line.split(':', 1) values = np.array([float(x) for x in values.strip().split()]) key = key.strip() if key in ['P0', 'P1', 'P2', 'P3']: calib[key] = values.reshape(3, 4) elif key == 'R0_rect': calib[key] = values.reshape(3, 3) elif key == 'Tr_velo_to_cam': calib[key] = values.reshape(3, 4) return calib def load_point_cloud(bin_file): """ 读取雷达点云:每行4个数 x,y,z,intensity,仅取xyz。 """ points = np.fromfile(bin_file, dtype=np.float32).reshape(-1, 4) return points[:, :3] # N x 3 def project_velo_to_image(pts_3d, calib): """ 输入:雷达坐标系下的Nx3点云 输出:像素坐标 u, v, 深度 depth,以及有效点的索引 """ # 1. 过滤无效点:NaN 或 inf finite_mask = np.isfinite(pts_3d).all(axis=1) pts_3d = pts_3d[finite_mask] # 2. 补齐次坐标 -> N x 4 n = pts_3d.shape[0] pts_3d_homo = np.hstack((pts_3d, np.ones((n, 1)))) # N x 4 # 3. 雷达到相机坐标 Tr = calib['Tr_velo_to_cam'] # 3x4 cam_pts = (Tr @ pts_3d_homo.T).T # N x 3 # 4. 矫正旋转 R0_rect R0 = calib['R0_rect'] # 3x3 cam_pts = (R0 @ cam_pts.T).T # N x 3 # 5. 过滤相机后方的点(z <= 0 必须扔掉) valid_z = cam_pts[:, 2] > 0 cam_pts = cam_pts[valid_z] # 6. 补齐次坐标并做内参投影 m = cam_pts.shape[0] cam_pts_homo = np.hstack((cam_pts, np.ones((m, 1)))) # M x 4 P2 = calib['P2'] # 3x4 pixel_homo = (P2 @ cam_pts_homo.T).T # M x 3 # 7. 透视除法,取 u,v u = pixel_homo[:, 0] / pixel_homo[:, 2] v = pixel_homo[:, 1] / pixel_homo[:, 2] depth = cam_pts[:, 2] return u, v, depth, valid_z, finite_mask

这段代码有几点值得注意:

  • 齐次坐标的乘法要弄清维度:Tr是3x4,点云是Nx4,Tr @ 点云.T得到3xN,再转置成Nx3。别写反。
  • R0_rect虽然是3x3,但补成4x4再做矩阵乘法更省事。如果你想省一步,可以在外参矩阵层面直接合并:
R0_ext = np.eye(4) R0_ext[:3, :3] = calib['R0_rect'] Tr_ext = np.eye(4) Tr_ext[:3, :] = calib['Tr_velo_to_cam'] combined = (R0_ext @ Tr_ext)[:3, :] # 3x4

这样后面投影只需一个矩阵乘。

  • 过滤z <= 0的点是必须的。相机后方的点如果不过滤,计算出的u/v坐标会是负值或翻转值,看起来像噪点一样乱跳。这也是新手最容易忽略的地方。

3.3 边界裁剪与可见性处理

投影得到的u、v是浮点数,先转成整数,然后判断是否落在图像范围内:

img_h, img_w = img.shape[:2] u_int = np.round(u).astype(int) v_int = np.round(v).astype(int) within = (u_int >= 0) & (u_int < img_w) & (v_int >= 0) & (v_int < img_h) u_valid = u_int[within] v_valid = v_int[within] depth_valid = depth[within]

这时候图上有大量点,如果不做遮挡处理,直接全部画上去,远处的点会覆盖近处的点,画面会显得很乱。更合理的做法是:对每个像素位置,只保留最近的那个点。可以用字典或排序实现:

# 简单深度排序法(painter's algorithm) order = np.argsort(depth_valid) # 从近到远 # 先画远的,再画近的,这样近的点覆盖远处的点 u_sorted = u_valid[order] v_sorted = v_valid[order] depth_sorted = depth_valid[order] # 也可以用 z-buffer 思路:建一个深度数组,记录每个像素的最小深度

对于静态图像调试,直接按深度从远到近绘制就够直观了。如果要做实时融合,建议用z-buffer:维护一个与图像等大的深度数组,初始化无穷大,遍历点云,只有当前点的深度小于数组中存储的深度时才更新像素颜色和深度。这能避免大量排序开销。

3.4 可视化与上色

有了u、v、depth,画到图像上就很简单了。可以按距离设置颜色:近处红色、远处蓝色,中间黄色过渡,类似热力图。用matplotlib或者OpenCV都行。

import matplotlib.pyplot as plt # 颜色映射:深度归一化到0~1,使用jet colormap norm_depth = (depth_sorted - depth_sorted.min()) / (depth_sorted.max() - depth_sorted.min() + 1e-8) colors = plt.cm.jet(norm_depth)[:, :3] # N x 3 fig, ax = plt.subplots(figsize=(12, 6)) ax.imshow(img) ax.scatter(u_sorted, v_sorted, c=colors, s=1) plt.show()

也可以反过来,把图像的颜色赋给点云,得到彩色点云:

colors_rgb = img[v_valid, u_valid] # 注意先v后u # 然后用 Open3D 或 matplotlib 绘制带颜色的3D点云

这个彩色点云对后续做语义分割、目标检测都很实用,相当于把相机的纹理信息"贴"到了雷达的几何结构上。我在做融合可视化时最喜欢这个玩法:图像上叠加雷达深度点,点云上又叠加图像颜色,两个方向来回验证。

4. 纠错与优化:常见问题排查和工程经验

4.1 投影结果不对,先查这四个地方

问题一:投影出来的点完全镜像,或上下颠倒

大概率是相机坐标系方向搞错,或者旋转矩阵填反了。检查方法:先打印几个已知坐标的点,比如雷达正前方10米的一个点,看它投影后是不是在图像中央偏下的位置。如果跑到图像上方或左右镜像,就把旋转矩阵的行列顺序调整一下。我个人调试时习惯先用一个极小的点云(几个点)做测试,确认方向对了再上全量数据。

问题二:图像上只有零星几个点,大部分消失

可能原因有两个:一是z<=0的点被过滤后剩下的太少,说明外参平移量或旋转有问题,把点都转到相机后面去了;二是边界裁剪条件写错,比如把v < 0写成了v > 0。建议把"过滤前点数""z有效点数""边界内点数"都打印出来,看哪一步骤数量骤降。

问题三:点云投影结果像一团乱麻,没规律

这通常是矩阵乘法维度或顺序错了。常见错误包括:Tr_velo_to_cam和R0_rect的顺序搞反,应该先做Tr再做R0;或者P2直接用成了P0,而P0对应的是另一台相机。如果项目里有多台相机,一定要确认图像来自哪台相机,用对应的投影矩阵。

问题四:图像上出现了两个方向相反的"鬼影"

说明没有过滤相机后方的点。比如一个在相机后面但z坐标为负的点,除以负的z会得到一个看似合理的像素坐标,但实际方向是反的。把所有z<=0的点直接丢弃,这个问题就消失了。

4.2 工程化经验:实时性、精度与可视化

实时融合场景下,点云动辄几万到十几万个点,用for循环逐点投影肯定是扛不住的。上面代码全部采用numpy矩阵运算,一次完成所有点的变换,速度很快。如果还想再快,可以先用体素滤波或直通滤波把不必要的点去掉,比如只保留相机前方50米范围内的点。

深度上色时,雷达返回的z值跨度很大,近处1米、远处80米,直接线性映射会让近处细节几乎看不清。我用过一个简单方法:对深度取对数再归一化,这样近处和远处的层次都能体现。类似:

log_depth = np.log1p(depth_sorted) norm_depth = log_depth / log_depth.max()

还有一个经验:调试阶段不要把点画得太小,先画成半径2~3像素的小圆点,肉眼判断对齐质量更容易。对齐准确的话,点应该恰好落在图像中对应物体轮廓上,比如路灯杆的点云投影成一条竖线,车辆的点云投影成一个规则的矩形轮廓。看到这个效果,你才能确认外参内参都标定正确了。

精度方面,如果投影点和物体边缘总是差几个像素,可能是标定文件里的外参精度不够,或者相机图像分辨率与内参不匹配。差得不多的情况可以在像素坐标上加一个固定的偏移补偿,但这是临时手段,治本还是要重新标定。

5. 扩展到彩色点云与双向验证

5.1 相机图像给点云"上色",能得到什么

把图像的颜色赋给点云之后,原本只有xyz和强度信息的稀疏点云,一下子就有了纹理信息。比如远处一辆车的点云,从几何形状可能只是几个小块,但贴上颜色后能看清车身的红漆,这对识别算法非常有价值。

实现上很简单:先用投影得到每个点的u,v坐标,再从图像的对应像素取值。注意图像的坐标顺序:像素数组是img[v, u],不要写成img[u, v]。这一步写反的话颜色会错乱,而且肉眼很难察觉,我踩过这个坑。

5.2 反向验证:让二维图像点回到三维空间

验证投影是否正确的一个好方法是做反向操作。取图像上某个物体的像素坐标,结合深度值反投影到雷达坐标系,看得到的3D点是否落在原来物体的点云位置上。这个"正投影+反投影"闭环检查,比单纯看一张效果图可靠得多。

我在实操中会用这个办法验证外参:找图像上一个明显的角点,比如路标边缘的转角,记录像素坐标,再在点云里找到同一个物理点,比较两者投影结果是否一致。如果差得远,说明标定文件可能用过时的参数,需要重新标定。

5.3 实时融合场景的一点建议

如果要把这个流程做成实时可视化,建议把投影计算和渲染拆开:投影部分用numpy或者更底层的CUDA实现,渲染部分交给图形管线。每一帧只对新增点云做变换,历史点云可以缓存。大多数项目不需要做到这种程度,但提前想好架构,后面扩展不痛苦。

一些最后想说的经验

这套流程我前前后后跑通过很多遍,最大的体会是:多传感融合里,算法公式远没有"坐标系方向"和"矩阵拼接顺序"害人。很多同学卡在投影结果乱七八糟上,不是不懂原理,而是某个旋转矩阵少转了一列、某个平移向量符号写反了,或者在处理齐次坐标时把行向量和列向量的乘法写错了。调试的时候,建议把所有中间结果都打印出来,逐环节验证。先做小规模测试,确认方向正确,再全量跑,这样能省下大量排查时间。还有一个小技巧:calib文件里的矩阵,如果拿不准方向,就先用单位矩阵代替某一部分,观察投影结果的变化趋势,往往一眼就能看出问题在哪。

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

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

立即咨询