简介:面向自动驾驶与计算机视觉学习者的点云着色实践包,围绕激光雷达与摄像头融合,用C++结合PCL、OpenCV完成2D图像颜色到3D点云的映射,并以KITTI同步数据作为真实验证场景。资源共26个文件,包含7个txt说明、6个pcd点云、6个png图像、6个bin原始雷达数据及1个cpp核心实现,压缩包约16.2MB,另附CMake构建脚本,便于直接编译复现实验。已有4155人学习下载。包内提供从数据读取、点云预处理、图像像素处理到相机投影与坐标对应关系的完整代码框架,涉及相机模型、像素坐标与三维点云的投影变换、噪声滤除和颜色赋值等关键环节。读者可据此理解点云着色流程、传感器融合思路及KITTI数据组织方式,并通过样例数据验证投影匹配效果,适合具备C++与PCL基础、希望深入自动驾驶感知的中高级学习者。
1. 点云着色先别急着写代码:先想清楚颜色从哪来
把一帧灰白色的点云铺在屏幕上,旁边是同一时刻拍到的彩色图像,大多数人第一反应都是让这两份数据对上:每个三维点从图像里取一个颜色,点云立刻从抽象几何变成能看懂场景的结构。这就是点云着色,激光雷达摄像头融合里最基础的一步。它不依赖深度学习,也不训练任何模型,核心工作只有三件:把标定参数读对、把三维点投到像素平面、把像素颜色回填给点。难点不在公式,而在坐标系、乘法顺序和边界判断这些细节,任何一处错位,输出就是一张颜色错乱的点云。这份整理适合刚接触传感器融合、手里有 PCD 和对应图像却对不上号的开发者,也适合想把着色管线接进自己 C++ 工程的人。
2. 标定文件拆解:R0_rect、Tr_velo_to_cam、P2 各管哪一段坐标
颜色错乱的根源,十有八九是标定没读对。激光雷达和相机是两套独立坐标系,雷达点以米为单位描述空间中某点的位置,相机则以像素描述成像结果,中间必须靠标定参数搭桥。很多公开自动驾驶数据集会把标定写成一行行文本,初看像天书,其实每行只承担一个明确职责。把这几个矩阵的分工想清楚,后面代码里每一步乘法才有依据,而不是照抄一段别人写的投影函数。
2.1 标定文本里到底有几组数
常见格式里会出现三类关键项:Tr_velo_to_cam、R0_rect、P2(或P0到P3)。它们不是随意摆放的,而是一条完整的坐标变换链。Tr_velo_to_cam是一个 3×4 的刚体变换矩阵,把激光雷达坐标系下的点转换到相机 0 坐标系;前半部分 3×3 是旋转,最后一列 3×1 是平移。R0_rect是 3×3 的旋转矩阵,把相机 0 的坐标修正到校正后的相机坐标系,这一步在双目或做了图像校正的数据里必须乘上,漏掉就会出现整体轻微错位。P2是 3×4 的投影矩阵,把校正相机坐标系下的三维点压到图像像素平面,它内部已经包含了焦距、主点和相机间基线。
三类参数串起来的顺序是固定的:先做刚体变换,再做校正旋转,最后做投影。顺序颠倒,或者把 4×4 和 3×4 混着乘,结果就会飞到图像外面。实际工程里我一般把 3×4 的矩阵补成 4×4 齐次形式,这样所有变换都能用同一个乘法接口,减少维数不一致带来的低级错误。
| 参数 | 维度 | 作用 | 常见误用 |
|---|---|---|---|
Tr_velo_to_cam | 3×4 | 雷达坐标到相机 0 坐标 | 忘记补最后一行齐次 |
R0_rect | 3×3 | 相机 0 到校正相机 | 直接跳过不乘 |
P2 | 3×4 | 校正相机到像素 | 和P_rect混淆 |
| 图像尺寸 | 标量对 | 边界裁剪 | 用错相机对应的分辨率 |
这张表建议贴在调试代码旁边。每次结果不对,先对照表格确认乘到第几步,比盲目改参数快得多。
2.2 读文件的代码:把一行字符串变成 4×4 矩阵
标定文件是文本,直接按行解析即可。关键是判断每行前缀,并把冒号后面的数字按行主序填进矩阵。下面这段代码只做解析,不涉及任何投影逻辑,方便单独测试。
#include <Eigen/Dense> #include <fstream> #include <sstream> #include <string> #include <vector> struct Calib { Eigen::Matrix4f T_velo_to_cam = Eigen::Matrix4f::Identity(); // 补齐为 4x4 Eigen::Matrix4f R0_rect = Eigen::Matrix4f::Identity(); // 补齐为 4x4 Eigen::Matrix<float, 3, 4> P_rect = Eigen::Matrix<float, 3, 4>::Zero(); }; // 只解析以 key 开头的行,返回冒号后的浮点数序列 static bool parseRow(const std::string& line, const std::string& key, std::vector<float>& out) { if (line.rfind(key, 0) != 0) return false; std::string rest = line.substr(line.find(':') + 1); std::istringstream iss(rest); float v; out.clear(); while (iss >> v) out.push_back(v); return true; } Calib loadCalib(const std::string& path) { Calib c; std::ifstream fin(path); std::string line; std::vector<float> v; while (std::getline(fin, line)) { if (parseRow(line, "Tr_velo_to_cam", v) && v.size() == 12) { // 12 个数按行主序填进 3x4,再补齐次最后一行 for (int r = 0; r < 3; ++r) for (int col = 0; col < 4; ++col) c.T_velo_to_cam(r, col) = v[r * 4 + col]; c.T_velo_to_cam(3, 3) = 1.0f; } else if (parseRow(line, "R0_rect", v) && v.size() == 9) { for (int r = 0; r < 3; ++r) for (int col = 0; col < 3; ++col) c.R0_rect(r, col) = v[r * 3 + col]; c.R0_rect(3, 3) = 1.0f; } else if (parseRow(line, "P2", v) && v.size() == 12) { for (int r = 0; r < 3; ++r) for (int col = 0; col < 4; ++col) c.P_rect(r, col) = v[r * 4 + col]; } } return c; }这段代码的逻辑很直接:parseRow负责前缀匹配和数值提取,loadCalib负责按字段名分派。v.size()的判断是防御性写法,防止某行因为格式差异少几个数却悄悄填错。T_velo_to_cam和R0_rect补(3,3)=1是为了让齐次坐标的 w 分量保持 1,后续做 4×4 乘法时不会破坏坐标。P_rect保持 3×4,因为投影后得到的是三维齐次像素坐标,不需要补成方阵。
提示:不同数据集的字段名可能带后缀或大小写差异,解析前先打印一遍文件前几行,确认前缀完全匹配,比事后排查省时间。
参数上,v.size() == 12对应 3×4,v.size() == 9对应 3×3。如果某套标定把旋转和平移分开写,就需要额外合并,不能直接套这段。校准文件路径最好做成命令行参数,避免硬编码在代码里。
3. 投影公式落地:把三维点压到像素平面的三步乘法
标定读对之后,真正决定着色质量的是投影这一步。投影不是简单地除以深度,而要处理齐次坐标、深度正负和图像边界。很多人第一次写投影,点云会整体偏到图像一角,或者一半点消失,问题往往出在变换链中间漏了一步,或者深度判断写在了错误的位置。把这一步拆成清晰的三个阶段,出错时就能定位到具体阶段。
3.1 变换链:velo 到 cam0,再到 rectified,最后到像素
三个阶段分别是:雷达点到相机 0 的刚体变换、相机 0 到校正相机的旋转、校正相机到像素的投影。第一阶段用T_velo_to_cam乘齐次坐标,得到相机 0 下的三维点;第二阶段用R0_rect再乘一次;第三阶段用P_rect得到齐次像素坐标,最后除以 w 分量归一化。三步缺一不可,尤其是第二阶段,它不改变点的大致位置,但会修正镜头校正带来的系统性偏差,漏掉时近距离看着还行,远距离边缘就会明显错位。
坐标系方向也要提前确认:激光雷达通常 x 向前、y 向左、z 向上,相机通常 z 向前、x 向右、y 向下。这两个约定不同,但标定矩阵已经把这个转换包含进去了,不需要手动翻转。手动加负号反而会把结果弄反。
// 输入雷达点,输出像素坐标和相机深度;返回 false 表示该点不可用 bool projectPoint(const Eigen::Vector3f& p_velo, const Calib& c, int W, int H, float& u, float& v, float& z_cam) { Eigen::Vector4f ph(p_velo.x(), p_velo.y(), p_velo.z(), 1.0f); Eigen::Vector4f p_cam = c.T_velo_to_cam * ph; // 雷达 -> 相机 0 p_cam = c.R0_rect * p_cam; // 相机 0 -> 校正相机 // 相机背后或过近的点直接丢弃,避免除以负数 if (p_cam.z() <= 0.5f) return false; Eigen::Vector4f p_img = c.P_rect * p_cam; // 校正相机 -> 像素齐次坐标 if (p_img.w() <= 1e-6f) return false; u = p_img.x() / p_img.w(); v = p_img.y() / p_img.w(); z_cam = p_img.w(); // P 最后一行是 [0 0 1 0] if (u < 0.0f || u >= static_cast<float>(W)) return false; if (v < 0.0f || v >= static_cast<float>(H)) return false; return true; }逻辑说明:先把雷达点补成齐次坐标,乘T_velo_to_cam进入相机 0,再乘R0_rect进入校正相机。p_cam.z() <= 0.5f是深度裁剪,0.5 米是经验值,用来排除打在镜头后方和过近的噪点;这个阈值可以按传感器安装位置调整,但不要设成 0,否则除以接近零的数会让像素坐标爆炸。p_img.w()在标准投影矩阵下等于相机深度,直接拿来当z_cam用,比再算一次省事。
参数说明:W和H必须是对应相机的真实分辨率,P2对应的图像和P3对应的图像分辨率可能不同,混用会导致边界判断错位。u、v用浮点保存,边界判断也用浮点,最后取整时才转int,这样边缘点不会因为提前截断而漏掉。返回false的点不参与着色,保留原始坐标即可。
3.2 深度判断与边界裁剪的顺序
这段函数里,深度判断放在投影之前,边界判断放在投影之后,顺序不能反。先投影再判断深度,会对相机背后的点算出一个无意义的像素位置,虽然最后也会被边界过滤掉,但中间可能出现除零或极大值。深度阈值 0.5 米不是绝对标准,如果雷达安装在车顶、相机在挡风玻璃后,最近有效距离可能更大,按实际安装关系微调。
边界裁剪用u >= W而不是u > W,因为像素索引从 0 到 W-1,等于 W 已经越界。这个细节在图像右边缘和底边缘最容易暴露,表现为最后一列或最后一行点没有颜色。很多人用int(u)先截断再判断,结果u = W - 0.3被截成W - 1看似合法,但插值取色时又会越界,统一用浮点判断最稳。
4. C++/PCL 着色管线:从 PCD 到带颜色点云的完整实现
有了标定解析和单点投影,剩下的是把整帧点云跑一遍,并解决遮挡问题。直接遍历每个点、投影、取色,会得到一个能看的彩色点云,但前后物体重叠的地方颜色会互相穿透,近处物体被远处物体的颜色覆盖。工程上通常加一个深度缓冲,每个像素只保留最近的点,再统一回填颜色。PCL 负责点云读写,OpenCV 负责图像读取,两者配合就能搭出一条可复用的命令行管线。
4.1 依赖与数据结构
依赖只有三个:Eigen 做矩阵运算,PCL 读写 PCD,OpenCV 读图像。Eigen 通常随 PCL 一起提供,不需要单独安装。数据结构上,输入用pcl::PointCloud<pcl::PointXYZ>,输出用pcl::PointCloud<pcl::PointXYZRGB>,前者只有坐标,后者多一个 RGB 字段。深度缓冲用std::vector<float>按图像尺寸开,初始值设为浮点最大值,表示该像素还没有任何点占用。
#include <opencv2/opencv.hpp> #include <pcl/point_cloud.h> #include <pcl/point_types.h> #include <pcl/io/pcd_io.h> #include <limits> // 前文的 Calib、loadCalib、projectPoint 放在同文件或头文件里这三行包含是管线的基础,<limits>用来取浮点最大值初始化深度缓冲。PCL 的 PCD 读写接口在pcl/io/pcd_io.h,OpenCV 的图像读取在opencv2/opencv.hpp。如果工程里已经用了 PCL 的可视化模块,注意不要和 OpenCV 的窗口系统冲突,命令行工具只做文件读写最省心。
注意:
pcl::io::loadPCDFile读取二进制 PCD 时对字段顺序有要求,如果输入点云带强度字段,先确认它能否直接读进PointXYZ,必要时换成PointXYZI再手动转坐标。
4.2 z-buffer 遮挡处理与着色主循环
遮挡处理的核心是两遍遍历。第一遍只算每个点的像素位置和深度,把更近的点记录到深度缓冲和索引数组;第二遍按像素索引把颜色回填给对应点。这样每个像素只有一个点拿到颜色,前后关系由深度决定,不会出现颜色穿透。
int main(int argc, char** argv) { if (argc != 5) { std::cerr << "usage: colorize <cloud.pcd> <image.png> <calib.txt> <out.pcd>\n"; return -1; } pcl::PointCloud<pcl::PointXYZ>::Ptr cloud(new pcl::PointCloud<pcl::PointXYZ>); if (pcl::io::loadPCDFile(argv[1], *cloud) < 0) return -2; cv::Mat img = cv::imread(argv[2], cv::IMREAD_COLOR); // 默认 BGR if (img.empty()) return -3; Calib calib = loadCalib(argv[3]); const int W = img.cols, H = img.rows; // 深度缓冲和最近点索引,按像素一一对应 std::vector<float> zbuf(static_cast<size_t>(W) * H, std::numeric_limits<float>::max()); std::vector<int> owner(static_cast<size_t>(W) * H, -1); // 第一遍:只填深度,不取色 for (size_t i = 0; i < cloud->size(); ++i) { const auto& p = cloud->points[i]; float u, v, z; if (!projectPoint(Eigen::Vector3f(p.x, p.y, p.z), calib, W, H, u, v, z)) continue; int px = static_cast<int>(u); int py = static_cast<int>(v); size_t idx = static_cast<size_t>(py) * W + px; if (z < zbuf[idx]) { zbuf[idx] = z; owner[idx] = static_cast<int>(i); } } // 初始化输出,未命中投影的点统一标灰 pcl::PointCloud<pcl::PointXYZRGB> out; out.resize(cloud->size()); for (size_t i = 0; i < cloud->size(); ++i) { out[i].x = cloud->points[i].x; out[i].y = cloud->points[i].y; out[i].z = cloud->points[i].z; out[i].r = out[i].g = out[i].b = 128; } // 第二遍:按像素给最近点赋色,注意 BGR 转 RGB for (int py = 0; py < H; ++py) { for (int px = 0; px < W; ++px) { int k = owner[static_cast<size_t>(py) * W + px]; if (k < 0) continue; const cv::Vec3b& bgr = img.at<cv::Vec3b>(py, px); out[k].b = bgr[0]; out[k].g = bgr[1]; out[k].r = bgr[2]; } } pcl::io::savePCDFileBinary(argv[4], out); std::cout << "colored " << out.size() << " points\n"; return 0; }逻辑说明:第一遍遍历所有点,能投影到图像内的点按像素位置索引深度缓冲,只有深度更小的点才更新缓冲和索引,这等价于在图像平面上做了一次可见性判断。第二遍按像素扫描,把owner指向的点的颜色改成图像对应位置的像素值。未命中投影的点保留灰色,方便肉眼判断哪些点落在图像范围外。输出用二进制 PCD,体积小、读写快。
参数说明:argc != 5限定四个参数,顺序是输入点云、图像、标定文件、输出点云。zbuf用浮点最大值初始化,保证第一个点一定能写入。owner初始化为 -1,表示该像素没有点占用。颜色赋值时bgr[0]是蓝、bgr[2]是红,和PointXYZRGB的字段顺序对应,这一步写反是整片点云偏蓝的常见原因。
4.3 输出与快速目检
输出 PCD 之后,用 PCL 的可视化工具打开,或者把点云再投影回图像上画圈,都能快速判断颜色是否对齐。我一般先看三个位置:图像左边缘、图像底部、远近物体交界处。左边缘能暴露水平方向偏移,底部能暴露垂直方向偏移,交界处能暴露遮挡处理是否生效。如果整片点云颜色错乱,先别怀疑投影公式,回到标定解析确认矩阵有没有填错行。
# 命令行跑一帧,参数顺序和 main 里一致 ./colorize 000000.bin.pcd 000000.png calib.txt colored.pcd这条命令只做一件事:读入点云和图像,按标定投影,输出带颜色的 PCD。文件名换成自己的即可。如果输出点云全是灰色,说明没有任何点投影成功,优先检查标定文件路径和图像尺寸;如果只有部分点有色,检查深度阈值和边界判断。
5. 避坑排查:投影对不齐、颜色反了的五个常见原因
投影代码写完,第一次跑出结果,很少有人一次就对。下面这几类问题我都在不同项目里遇到过,按现象、原因、解决三步整理,照着排查能省掉大量试错时间。
5.1 方向类:上下颠倒或左右镜像
现象是点云整体投到图像上半部,或者左右完全反了。原因通常是变换链里漏乘了R0_rect,或者手动加了坐标翻转。解决方法是先把一个已知点单独打印出来,按三个阶段分别输出坐标,确认每一步的数值变化是否符合预期。如果只有R0_rect那一步之后位置偏了,说明校正矩阵没读对;如果第一步就偏,检查T_velo_to_cam是否按行主序填充。
5.2 颜色类:整片点云发蓝或发暗
现象是红色物体在点云里显示成蓝色。原因是 OpenCV 默认按 BGR 读图,而PointXYZRGB的字段顺序是 R、G、B,直接按数组下标赋值就会把蓝通道写到红通道。解决方法是赋值时显式对调,r取bgr[2]、b取bgr[0]。发暗则可能是图像读取时用了灰度模式,或者 PCD 输出时颜色字段被截断,检查imread的标志位和输出点类型。
5.3 遮挡类:近处物体被远处颜色覆盖
现象是前后物体重叠处颜色穿透,近处物体表面出现远处背景的颜色。原因是没有做深度缓冲,后遍历到的点把先遍历到的颜色覆盖了。解决方法是按 4.2 的两遍遍历,第一遍只记深度和索引,第二遍统一赋色。如果场景里点很稀疏,也可以放宽为每个像素保留最近三个点做加权,但大多数情况下单点足够。
5.4 边界类:图像边缘一圈点没颜色
现象是点云在图像四周有一圈灰色。原因是边界判断用了int截断,或者判断条件写成了>而不是>=。解决方法是投影阶段用浮点判断u < W和v < H,取整放到最后;如果还需要边缘插值取色,先做双线性采样再判断,避免索引越界。图像分辨率和标定里P2对应的分辨率不一致也会造成同样现象,核对一下宽高。
5.5 时序类:静止场景对得齐,车一动就偏
现象是静态标定看着没问题,场景里有运动物体时颜色明显错位。原因是雷达帧和图像帧的时间戳没有对齐,用了不同时刻的数据。解决方法是按时间戳做最近邻匹配,运动剧烈时做线性插值,或者至少保证两帧时间差在传感器周期的一半以内。这一条不影响投影公式的正确性,但会直接决定动态场景的可用性。
6. 验证与进阶:怎么确认颜色贴对了,以及时序与多相机怎么扩
着色跑通只是起点,真正决定这套管线能不能进工程的是验证。单看一张彩色点云很难判断对错,因为颜色错位在稀疏点云上并不总是显眼。我习惯把投影后的点再画回图像上,用圆圈标出每个点的落点,和图像里的物体边缘对比。边缘对齐、远近关系正确、遮挡处没有穿透,这三条同时满足,才认为这一帧可信。
6.1 定量验证:投影残差与目检结合
目检之外,可以选几个图像里容易定位的角点,比如路牌四角或车辆尾灯,手动量出它们在图像上的像素坐标,再和投影结果对比,算一个像素级残差。残差在几个像素以内属于正常,超过十个像素就要回头查标定。批量验证时,把每一帧的残差统计出来,看是否稳定;如果某些帧突然变大,多半是时间戳没对齐或标定文件拿错了。
// 把投影点画到图像上,用于目检 for (const auto& p : cloud->points) { float u, v, z; if (!projectPoint(Eigen::Vector3f(p.x, p.y, p.z), calib, W, H, u, v, z)) continue; cv::circle(img, cv::Point(cvRound(u), cvRound(v)), 1, cv::Scalar(0, 255, 0), -1); // 绿色小圆点 } cv::imwrite("overlay.png", img);这段叠加代码把每个成功投影的点画成绿色小圆,圆点密集处就是点云覆盖区域。看圆点是否贴合物体轮廓,比看彩色点云直观。cvRound做四舍五入,避免截断带来的半像素偏移。圆点半径设 1 像素,太大会糊成一片,太小在缩略图上看不见。
6.2 进阶:时序同步与多相机扩展
单相机单帧跑通之后,常见的扩展方向有两个。一是时序同步,雷达和相机帧率不同,按时间戳做匹配,必要时插值,动态场景才不会错位。二是多相机,标定文件里通常有P0到P3多组投影矩阵,它们共享同一个R0_rect,但各自对应不同相机和分辨率。扩展时把Calib结构里的单个P_rect改成数组,投影函数多传一个相机索引,其余逻辑不变。
struct MultiCalib { Eigen::Matrix4f T_velo_to_cam = Eigen::Matrix4f::Identity(); Eigen::Matrix4f R0_rect = Eigen::Matrix4f::Identity(); std::vector<Eigen::Matrix<float, 3, 4>> P_list; // P0..P3 };多相机版本把投影矩阵存成列表,每个相机单独做一次投影和着色,输出可以合并成一个带相机编号的点云,也可以按视场角分段。这里的坑在于不同相机的图像分辨率可能不同,边界判断必须用各自图像的宽高,不能共用一套。
说个自己的教训:早期做这套管线时,我图省事把深度阈值写死成 0.1,结果近距离地面点全被裁掉,彩色点云底部缺了一块,排查了半天才定位到阈值。从那以后,每次调投影参数都先把阈值、图像尺寸、标定路径打印到日志里,跑一帧看一眼,再批量处理。希望这些细节能帮你在自己的数据上少走几步弯路。
本文还有配套的精品资源,点击获取