ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PCL点云上色与可视化全指南:从高度、强度到分割结果着色技巧

PCL点云上色与可视化全指南:从高度、强度到分割结果着色技巧

做点云处理,总有一道绕不过去的坎:你算出了结果,打开可视化工具一看却是一团没有层次的灰白色点云——想分辨高度差个几厘米的台阶、想看清不同物体的边界、想告诉旁边同事“这片区域是天花板,那片是地面”,可屏幕上根本看不出所以然。这时候你需要的不是继续调算法参数,而是先学会一件事:给点云上色。

我用 PCL(Point Cloud Library)做三维数据处理也有几年了,踩过的坑不少,其中“可视化调了半天,最后发现只是不会上色”这种情况占了很大比例。点云本身只是一个三维坐标集合,配上颜色之后才真正“会说话”:既能表达高度、强度、法线方向这些几何属性,也能表达分割、聚类这些语义信息。这篇文章我把自己常用的几种上色方法整理成一份笔记,每个方法都附有可复现的代码思路和实测经验,适合刚接触 PCL、或者做可视化调试时总被颜色问题卡住的朋友。

1. 颜色数据模型:先搞清楚 RGB 到底存在哪里

1.1 PointXYZRGB 的本质:颜色和坐标共用一段内存

很多新手拿到点云第一件事就是写cloud->points[i].rgb = 0xFF0000,然后发现颜色没生效,或者渲染出来是乱的。这不是眼神不好,而是没弄明白 PCL 里颜色的存储规则。

PCL 中最常用的带颜色点类型是pcl::PointXYZRGB,它的内存布局其实是 4 个 float:x、y、z各占 4 字节,紧接着一个叫做rgb(也可能是rgba)的字段也占 4 字节。这个rgb字段并不是一个真正的小数,而是把 R、G、B、A 四个 uint8 打包到一个 32 位整数里,再把这个整数的位模式原封不动地解释成 float。所以你直接写p.rgb = 0xFF0000,在绝大多数平台上都会被当做一个很小的浮点数来解析,实际赋进去的 R、G、B 值全是错的。

正确的操作是直接操作 PCL 已经定义好的位域成员:p.r、p.g、p.b、p.a,它们本身是uint8_t,赋值最直观。底层实现是这样的:

union RGBA { struct { uint8_t r, g, b, a; }; float rgba; uint32_t rgba_u32; };

你赋值p.r = 255; p.g = 128; p.b = 0;和赋值一整块内存等效,只是 PCL 按位域方式帮你屏蔽了字节序细节。换句话说,平常写代码时把r/g/b当普通变量用就好,别去碰那个 float。这个解释我见过太多人踩坑,值得在文章最前面写清楚。

1.2 在 PointXYZ 和 PointXYZRGB 之间转换

如果你的原始数据是pcl::PointCloud<pcl::PointXYZ>,要上色就得先转成PointXYZRGB。最简单的方法是pcl::copyPointCloud:

pcl::PointCloud<pcl::PointXYZ>::Ptr xyz_cloud(new pcl::PointCloud<pcl::PointXYZ>); pcl::PointCloud<pcl::PointXYZRGB>::Ptr cloud(new pcl::PointCloud<pcl::PointXYZRGB>); pcl::copyPointCloud(*xyz_cloud, *cloud);

这段代码会把 x、y、z 以及 intensity、normal 等两边共有的字段一并复制,但不会凭空生成颜色字段——复制完成后所有点的 r、g、b 都是 0,也就是黑色。你要做的只是在后续循环里给每个点设置颜色。

还有一点值得注意:PointXYZRGB和PointXYZ在 PCD 文件里的字段名不同,前者写入时带rgb字段。如果你直接用savePCDFileASCII保存,文件里能看到类似rgb float的字段声明;用二进制模式保存时,则是在数据区按 RGB 的顺序打包。理解这一点对排查“保存后颜色丢失”的问题非常有用。

1.3 常用可赋色点类型速查

点类型可存储内容适用场景
pcl::PointXYZRGBxyz + rgb最常用的上色目标,支持可视化直接渲染
pcl::PointXYZRGBAxyz + rgba需要透明度时使用
pcl::PointXYZIxyz + intensity激光雷达强度数据显示,可后续转伪彩色
pcl::PointXYZLxyz + label分割结果标号存储
pcl::PointXYZRGBLxyz + rgb + label同时需要颜色和语义标签时
pcl::PointNormalxyz + normals + curvature法线估计输出,可在可视化时按法线字段上色

我在实际项目中通常这样分工:后端算法输出统一的PointXYZRGB,颜色用来临时表达某个中间结果;如果需要长期保存算法语义(比如聚类编号),那就用PointXYZRGBL,一个文件把所有信息都带走。后面所有上色方法,最终目标也都是往PointXYZRGB的 r/g/b 里填数。

2. 按高度上色:快速让点云“分层”

2.1 为什么 Z 高度上色最实用

三坐标数据里最容易直接观察的属性就是高度。室内点云里地面到桌面、桌面到天花板,Z 值差异明显;室外道路点云里路沿、台阶、边坡也是靠高度区分。给高度上色并不是让点云变“漂亮”,而是让几何结构一眼就能被人的视觉系统捕捉到。

原理非常简单:把每个点的 z 值归一化到一个区间,再映射成颜色。归一化之后,低处偏冷色调、高处偏暖色调,视觉上就形成了一条条“等高线”。处理一栋楼的点云时,按高度上色甚至能直接看清哪些区域属于不同楼层,比纯灰点云高效十倍。

2.2 Z 值归一化与 Jet 色带映射代码

上色前先算 z 的 min max。这里有个容易犯的错误:直接把整个点集里最大最小 z 拿来用。如果点云里有飞点或 NaN 值,最小值和最大值会被拉到夸张的范围,导致中间层次完全分不出来。所以我习惯先剔除非有限值,再用分位数或者直接手动指定范围。

#include <pcl/point_cloud.h> #include <pcl/point_types.h> #include <pcl/io/pcd_io.h> #include <algorithm> #include <cmath> pcl::PointCloud<pcl::PointXYZRGB>::Ptr colorByHeight( const pcl::PointCloud<pcl::PointXYZ>::Ptr& input, float min_z, float max_z) { pcl::PointCloud<pcl::PointXYZRGB>::Ptr out(new pcl::PointCloud<pcl::PointXYZRGB>); pcl::copyPointCloud(*input, *out); float range = max_z - min_z; if (std::fabs(range) < 1e-6f) range = 1.0f; for (size_t i = 0; i < out->size(); ++i) { float z = out->points[i].z; if (!std::isfinite(z)) { out->points[i].r = 255; out->points[i].g = 0; out->points[i].b = 0; continue; } float t = (z - min_z) / range; t = std::max(0.0f, std::min(1.0f, t)); if (t < 0.25f) { out->points[i].r = 0; out->points[i].g = static_cast<uint8_t>(t / 0.25f * 255.0f); out->points[i].b = 255; } else if (t < 0.5f) { out->points[i].r = 0; out->points[i].g = 255; out->points[i].b = static_cast<uint8_t>((1.0f - (t - 0.25f) / 0.25f) * 255.0f); } else if (t < 0.75f) { out->points[i].r = static_cast<uint8_t>((t - 0.5f) / 0.25f * 255.0f); out->points[i].g = 255; out->points[i].b = 0; } else { out->points[i].r = 255; out->points[i].g = static_cast<uint8_t>((1.0f - (t - 0.75f) / 0.25f) * 255.0f); out->points[i].b = 0; } } return out; }

上面这段就是经典的 Jet 色带近似映射,低到高依次走蓝、青、绿、黄、红。比起 OpenCV 自带的 applyColorMap,这个手写版本的好处是无依赖、随时随地能塞进自己的 PCL 工程,而且分段逻辑容易按业务改——比如想强调中间层,只需要把 0.25 和 0.75 两个断点改成 0.1 和 0.9。

实际使用建议:如果不需要保存结果,只是看一眼,就用PointCloudColorHandlerGenericField在可视化层上直接指定字段"z",这样不用真的改变点数据。只有当你需要把带颜色的点云输出成 PCD 或 PLY 供其他模块复用时,才用上面的函数真正写入颜色字段。

2.3 高度上色的细节与经验

给高度上色时最常遇到的三个问题我列一下。第一,点云密度不均匀会导致视觉上颜色的“块状感”,这是正常现象,不代表上色失败。第二,同一个场景如果分多次按各自 min max 映射,色带范围会不一致,多块点云放在一起看时会出现“同一高度不同颜色”的错觉——专有做法是先整体统计全场景范围,再做全局映射。第三,自动取 z 的百分位比直接取 min max 更稳,比如只取 1% 到 99% 的范围,可以自动把孤立飞点排除在外。

3. 强度信息与伪彩色映射:让标量数据可视化

3.1 强度字段的意义:从点云中提取非几何属性

激光雷达采集的点云除了坐标还带有回波强度,也就是intensity字段。它反映目标表面对激光的反射能力,金属、墙面、植被、路面在强度值上差异明显。深度相机或三维扫描仪也可能输出灰度值或置信度。这类标量字段没有天然的颜色表达,所以需要把数值映射成可见颜色。

处理强度上色最稳妥的思路是:先用PointXYZI把点云读进来,统计强度最小值最大值,然后逐点把强度归一化到 [0, 1],最后映射成灰度或者彩色。灰度最简单,计算强度值时直接用t * 255赋给 r/g/b 三个通道即可,适合把点云当成黑白照片看;伪彩色则适合突出对比度。

3.2 灰度与热力图两种映射方式

灰度映射的代码:

pcl::PointCloud<pcl::PointXYZI>::Ptr in_cloud(new pcl::PointCloud<pcl::PointXYZI>); pcl::PointCloud<pcl::PointXYZRGB>::Ptr out_cloud(new pcl::PointCloud<pcl::PointXYZRGB>); pcl::copyPointCloud(*in_cloud, *out_cloud); float min_i = 1e9f, max_i = -1e9f; for (const auto& p : in_cloud->points) { if (std::isfinite(p.intensity)) { min_i = std::min(min_i, p.intensity); max_i = std::max(max_i, p.intensity); } } float range_i = max_i - min_i; if (range_i < 1e-6f) range_i = 1.0f; for (size_t i = 0; i < out_cloud->size(); ++i) { float v = in_cloud->points[i].intensity; if (!std::isfinite(v)) { out_cloud->points[i].r = 255; out_cloud->points[i].g = 0; out_cloud->points[i].b = 0; continue; } uint8_t gray = static_cast<uint8_t>(((v - min_i) / range_i) * 255.0f); out_cloud->points[i].r = gray; out_cloud->points[i].g = gray; out_cloud->points[i].b = gray; }

如果要伪彩色,把赋灰度那段换成第二章里的 Jet 色带函数即可。我自己的习惯是:灰度上色适合“看轮廓”,因为人的视觉对黑白明暗变化非常敏感;伪彩色上色适合“看数值分布”,比如强度异常高的反光点会在色带上直接呈现出红色尖刺,方便快速定位异常。

3.3 强度上色时容易忽略的坑

强度数据经常含有异常值,比如某些传感器会给到负强度,或者某些点的 intensity 标定为 0。如果直接拿 min max 做映射,负值会全部被压成黑色。我更推荐对强度值做一次截断:取 1% 到 99% 分位数左右的范围,超出部分统一设为端点颜色。这样既保留了绝大多数有效信息,又不会让一两个极端点毁掉整个色带的区分度。

另外要注意一点,PCL 里PointXYZI转成PointXYZRGB时,copyPointCloud不会自动把 intensity 复制到颜色里。有些教程里的实现只转了类型没转强度,实际出来的是一个全黑色点云。只要记得手动循环填充,这个坑就可以避开。

4. 基于法线和曲率上色:直观感知表面形态

4.1 法线分量映射:把朝向变成颜色

法线是点云几何分析里最常见的中间结果。pcl::NormalEstimation 算出来的法线有三个分量 nx、ny、nz,再加上一个曲率 curvature。给法线上色最简单的方式是取 nz 分量,因为 nz 能表达表面朝向的相对取向:地面点云的法线 nz 接近 1,垂直墙壁的法线 nz 接近 0,天花板的法线 nz 接近 -1。

我常用的做法是把 nz 从 [-1, 1] 映射到 [0, 255] 的灰度或伪彩色,这样整个场景里地面、墙壁、天花板会呈现明显不同的颜色。如果要更丰富的视觉效果,也可以把 nx、ny、nz 分别映射到 R、G、B 三个通道,这样每个点云的朝向变化都会带来连续的颜色变化。

// 假设 normals 是 pcl::PointCloud<pcl::Normal>::Ptr,点数和 xyz 一致 for (size_t i = 0; i < cloud_rgb->size(); ++i) { float nz = normals->points[i].normal_z; if (!std::isfinite(nz)) nz = 0.0f; float t = (nz + 1.0f) * 0.5f; // [-1, 1] -> [0, 1] uint8_t v = static_cast<uint8_t>(t * 255.0f); cloud_rgb->points[i].r = v; cloud_rgb->points[i].g = v; cloud_rgb->points[i].b = static_cast<uint8_t>((1.0f - t) * 255.0f); }

这段代码的效果是:朝向正上方的表面发亮白,朝向正下方的表面发蓝。注意 NormalEstimation 计算前最好对点云做一下体素滤波或者最近邻搜索半径设置合理,否则法线噪声会把颜色搞得一团糟。我建议setRadiusSearch取点云平均间距的 3 到 5 倍,太小噪声大,太大表面细节会被磨平。

4.2 曲率上色:让边界和细节凸显出来

曲率是法线估计时顺带得到的曲面弯曲程度指标,数值越大说明该点附近几何变化越剧烈。点云里物体边缘、孔洞边界、台阶拐角处的曲率通常明显高于平整表面。曲率上色的关键不是用什么颜色,而是怎么处理数值动态范围——曲率值往往集中在非常小的区间,比如 0.001 到 0.01,直接线性映射会让几乎整个点云都呈现同一种颜色。

我的处理套路是先取曲率的对数或者开根号。取对数会把分布拉开,但需要注意曲率等于 0 时对数无定义,要对非正数做保护。更省事的方法是直接用分位点:把曲率从小到大排序,取第 90 百分位作为映射上界,超过上界的点全部视为极端高曲率。这样可视化时,平整区域呈现冷色,边缘与角点呈现亮色,视觉效果非常清楚。

float max_c = 0.01f; // 实际使用建议用分位数 for (size_t i = 0; i < cloud_rgb->size(); ++i) { float c = normals->points[i].curvature; float t = std::min(1.0f, c / max_c); cloud_rgb->points[i].r = static_cast<uint8_t>(t * 255.0f); cloud_rgb->points[i].g = 0; cloud_rgb->points[i].b = static_cast<uint8_t>((1.0f - t) * 255.0f); }

5. 按索引、标签与分割结果上色:把语义画出来

5.1 按固定索引批量上色:给选区做标记

有时候你并不想按几何量映射颜色,而是想给某个范围内的点打上固定的标记色。比如标定板区域、兴趣区域、或者移动机器人导航时的高危区域,都可以通过索引集合统一染色。实现非常简单:

void paintIndexRegion(pcl::PointCloud<pcl::PointXYZRGB>::Ptr cloud, const std::vector<int>& indices, uint8_t r, uint8_t g, uint8_t b) { for (int idx : indices) { cloud->points[idx].r = r; cloud->points[idx].g = g; cloud->points[idx].b = b; } }

这种方式特别适合做区域对比:把同一帧点云的不同区域分别染成红、绿、蓝三种颜色,一个可视化窗口就能直观比较不同区域的空间关系。很多人喜欢把 SearchRadius 内的邻域点染色显示,也是用这个思路。

5.2 分割结果上色:给每个聚类一个颜色

点云分割算法(比如欧几里得聚类、区域生长、 RANSAC 平面分割)输出的通常是一组pcl::PointIndices,每个聚类包含若干点索引。处理这类结果时,需要根据聚类序号生成颜色表,再按索引批量染色。

std::vector<pcl::PointIndices> clusters; // 假设已经计算出来 std::vector<std::tuple<uint8_t, uint8_t, uint8_t>> palette; // 生成离散色盘:尽量让相邻颜色差异大一点 float golden = 0.6180339887f; for (size_t k = 0; k < clusters.size(); ++k) { uint8_t r = static_cast<uint8_t>(255.0f * (0.5f + 0.5f * std::sin(2.0f * M_PI * golden * k))); uint8_t g = static_cast<uint8_t>(255.0f * (0.5f + 0.5f * std::sin(2.0f * M_PI * golden * k + 2.0f))); uint8_t b = static_cast<uint8_t>(255.0f * (0.5f + 0.5f * std::sin(2.0f * M_PI * golden * k + 4.0f))); palette.emplace_back(r, g, b); } pcl::PointCloud<pcl::PointXYZRGB>::Ptr colored(new pcl::PointCloud<pcl::PointXYZRGB>); pcl::copyPointCloud(*xyz_cloud, *colored); for (size_t k = 0; k < clusters.size(); ++k) { auto [r, g, b] = palette[k % palette.size()]; for (int idx : clusters[k].indices) { colored->points[idx].r = r; colored->points[idx].g = g; colored->points[idx].b = b; } }

这样做的价值在于,算法输出的离散编号本身没有颜色含义,但可视化时同一聚类的点颜色相同,不同聚类颜色尽量不同,一眼就能看出分割结果是否合理。我在调试区域生长分割时就是这样上色的:地面被识别成一整块,桌面上的纸盒每个都是独立颜色,边缘粘连点在颜色上会表现出“两个色块接壤”的现象,问题一目了然。

使用这种离散色盘时,如果聚类数量小于 20,直接用固定调色板(红、绿、蓝、黄、青、紫等)更稳;如果聚类数量多到上百个,上面的黄金角取模生成方式能保证相近序号的聚类颜色差异也较大,比手写色板好使。

6. 上色的避坑宝典:从显示到保存的完整排查思路

6.1 可视化层上色 vs 数据层上色

我在 PCL 里给点云上色时,会先把“改数据”和“改显示”两件事分清楚。改数据是真正写入PointXYZRGB的 r/g/b 字段,PCD 文件保存后颜色跟着走;改显示则是用pcl::visualization::PointCloudColorHandler系列在渲染层临时指定颜色,不改原始点云。

pcl::visualization::PCLVisualizer::Ptr viewer(new pcl::visualization::PCLVisualizer("color demo")); // 方式一:按已有字段渐变显示,比如 z 或 intensity pcl::visualization::PointCloudColorHandlerGenericField<pcl::PointXYZ> field_color(cloud, "z"); viewer->addPointCloud<pcl::PointXYZ>(cloud, field_color, "cloud_field"); // 方式二:统一指定颜色,注意 ColorHandler 的类型是 uint8_t 颜色分量 pcl::visualization::PointCloudColorHandlerCustom<pcl::PointXYZ> custom_color(cloud, 0, 255, 0); viewer->addPointCloud<pcl::PointXYZ>(cloud, custom_color, "cloud_custom"); // 方式三:直接用点云已有的 rgb 字段 pcl::visualization::PointCloudColorHandlerRGBField<pcl::PointXYZRGB> rgb_color(cloud_rgb); viewer->addPointCloud<pcl::PointXYZRGB>(cloud_rgb, rgb_color, "cloud_rgb");

如果你只是想在调试时看一眼效果,用PointCloudColorHandlerGenericField零成本解决;如果你需要把带颜色的点云交给下游模块使用,就一定要改数据层并保存为带 rgb 字段的点类型。二者混着用经常导致“我明明加了颜色,怎么保存出来是黑的”这种疑问——实际上就是改在了不同的层。

6.2 高频问题排查速查表

现象可能原因解决方案
点云保存后全部黑色原始点类型没有 rgb 字段,或者 copyPointCloud 后忘了填充颜色确认最终保存类型是 PointXYZRGB/XYZRGBA
直接赋 rgb 浮点变量后颜色错乱字节顺序或位域理解错误用 PointXYZRGB 的 r/g/b 位域赋值,不要手动拼整数
高度映射全是一种颜色min/max 范围被 NaN 或飞点污染先过滤非有限值,用分位数确定范围
强度负数导致大片黑色强度被裁剪或数据本身有负值强度映射前做截断或偏移
法线颜色噪声很大法线估计半径太小,表面不连续setRadiusSearch 取平均点间距 3 到 5 倍
分割聚类颜色相近色盘生成方式不好使用黄金角取模或预置高对比色板
点云在 PCLVisualizer 里不显示颜色addPointCloud 未指定颜色 handler 或类型不含颜色按 6.1 的 handler 显式指定

6.3 一份可复用的完整示例:从读取、上色到保存

把前面几个方法串起来,最典型的使用场景如下:读取一个只有 XYZI 的点云,按高度映射为 Jet 色,保存为带颜色的 PCD 文件,再用可视化窗口显示。

#include <pcl/io/pcd_io.h> #include <pcl/point_types.h> #include <pcl/visualization/pcl_visualizer.h> pcl::PointCloud<pcl::PointXYZI>::Ptr raw(new pcl::PointCloud<pcl::PointXYZI>); pcl::io::loadPCDFile<pcl::PointXYZI>("input.pcd", *raw); float min_z = 0.0f, max_z = 3.0f; // 根据场景实际调整 pcl::PointCloud<pcl::PointXYZRGB>::Ptr colored(new pcl::PointCloud<pcl::PointXYZRGB>); pcl::copyPointCloud(*raw, *colored); // 此处省略 Z 到 Jet 色带的具体赋值循环,参考第 2.2 节 pcl::io::savePCDFileBinary("output_color.pcd", *colored); pcl::visualization::PCLVisualizer::Ptr viewer(new pcl::visualization::PCLVisualizer("viewer")); pcl::visualization::PointCloudColorHandlerRGBField<pcl::PointXYZRGB> rgb(colored); viewer->addPointCloud<pcl::PointXYZRGB>(colored, rgb, "colored_cloud"); while (!viewer->wasStopped()) { viewer->spinOnce(100); }

这个模板覆盖了大多数开发场景。你只需要替换中间“上色循环”的逻辑,就能复用高度、强度、法线、分割等任意一类上色需求。我自己维护了一个小的color_utils.hpp,里面放了 Jet 映射、灰度映射、离散色盘生成、按索引染色这几个函数,每次新项目直接 include,省掉大量重复代码。

最后再分享一个个人习惯。遇到要调试的效果,我从来不等全量点云跑完才开始排查,而是先裁剪一个小区域或者降采样到几千个点,快速跑一遍上色流程,确认颜色规律符合预期再铺到全量数据上。不是怕计算量大,而是小样本点云的可视化反馈快,定位问题比全量数据快一个数量级。颜色映射这件事,本质上就是把数值直觉变成视觉直觉,工具趁手了,点云自己就会开口说话。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进