4、点云可视化进阶:多窗口可视化,点云着色,添加坐标轴与网格

上一章我们学会了怎么把点云数据加载进来,并且用 Open3D 的默认窗口看一眼。说实话,那个默认窗口只能算「能看」,离「好用」还差得远。你想想看,实际项目中你面对的可能是一整片城区的点云,几千万个点堆在一起,光靠一个窗口、一种颜色,根本看不出门道。

这一章我们就来点真格的。我会带你搞定三件事:多窗口联动、按属性着色、加坐标轴和网格。这些都是我平时做点云标注、算法调试时每天都要用的基本功。

本章核心能力:学会用 Open3D 的 Visualizer 类,实现多视角同步观察、按高度/强度/分类着色、以及添加辅助参考系。这些技能在点云标注、SLAM 建图评估、自动驾驶感知结果可视化中几乎必用。

4.1 多窗口可视化:一个场景,多个视角

为什么要多窗口?我给你讲个真实场景。有一次我在调试一个地面分割算法,单窗口里看分割结果总觉得没问题,但换到俯视图一看——好家伙,算法把一辆车的车顶当成地面给滤掉了。这就是单视角的盲区。

Open3D 的 draw_geometries 虽然方便,但只能开一个窗口。要开多个窗口,得用底层的 Visualizer 类。我习惯的做法是这样的:

import open3d as o3d
import numpy as np

# 加载点云
pcd = o3d.io.read_point_cloud("data/urban.pcd")

# 创建两个可视化窗口
vis_front = o3d.visualization.Visualizer()
vis_top = o3d.visualization.Visualizer()

# 分别创建窗口
vis_front.create_window(window_name="前视图", width=640, height=480)
vis_top.create_window(window_name="俯视图", width=640, height=480)

# 添加相同的点云
vis_front.add_geometry(pcd)
vis_top.add_geometry(pcd)

# 设置视角
view_control_front = vis_front.get_view_control()
view_control_front.set_front([0, 0, -1])  # 前视

view_control_top = vis_top.get_view_control()
view_control_top.set_front([0, -1, 0])    # 俯视

# 开始渲染循环
while True:
    vis_front.poll_events()
    vis_front.update_renderer()
    vis_top.poll_events()
    vis_top.update_renderer()
    # 按 Q 键退出
    if not vis_front.poll_events() or not vis_top.poll_events():
        break

vis_front.destroy_window()
vis_top.destroy_window()

我的小技巧:多窗口渲染时,记得把两个窗口的 poll_events() 和 update_renderer() 放在同一个循环里。我曾经分开写两个循环,结果一个窗口卡死,另一个也动不了。

这段代码跑起来,你会看到两个窗口。左边是前视图,右边是俯视图。你旋转其中一个,另一个不会跟着动——这就是独立视角。如果你想实现联动视角(旋转一个,另一个也跟着转),那就得手动同步 view control 的参数。嗯,这个稍微复杂一点,但原理就是每次更新后把视角参数复制过去。

4.2 点云着色:让数据自己说话

默认的点云是灰色的,说实话跟看黑白电视差不多。着色能让数据里的信息一目了然。我常用的着色方式有三种:按高度、按强度、按分类。

4.2.1 按高度着色

这是最直观的一种。地面是蓝色,建筑物顶部是红色,中间渐变。说白了就是把 Z 轴值映射到颜色上。

def color_by_height(pcd, colormap='jet'):
    points = np.asarray(pcd.points)
    z = points[:, 2]
    z_min, z_max = z.min(), z.max()
    
    # 归一化到 [0, 1]
    z_norm = (z - z_min) / (z_max - z_min + 1e-8)
    
    # 使用 matplotlib 的 colormap
    import matplotlib.pyplot as plt
    cmap = plt.get_cmap(colormap)
    colors = cmap(z_norm)[:, :3]  # 去掉 alpha 通道
    
    pcd.colors = o3d.utility.Vector3dVector(colors)
    return pcd

pcd_height = color_by_height(pcd, 'jet')
o3d.visualization.draw_geometries([pcd_height])

注意:如果点云里有个别离群点(比如飞鸟、无人机),Z 轴范围会被拉得很大,导致大部分点颜色集中在很窄的区间。我建议先做一下统计滤波,或者手动设置 z_min 和 z_max 的阈值。

4.2.2 按强度着色

激光雷达除了返回位置,还会返回反射强度。不同材质的物体反射强度不一样——比如交通标志牌反射很强,沥青路面反射很弱。按强度着色能帮你快速区分路面、植被、建筑物。

def color_by_intensity(pcd, intensity_field='intensity'):
    # 检查是否有强度信息
    if not pcd.has_intensity():
        print("这个点云没有强度数据,换个文件试试")
        return pcd
    
    intensity = np.asarray(pcd.intensity)
    # 有些传感器的强度值范围是 0-255,有些是 0-1
    if intensity.max() > 1.0:
        intensity = intensity / 255.0
    
    # 用灰度表示强度
    colors = np.stack([intensity, intensity, intensity], axis=1)
    pcd.colors = o3d.utility.Vector3dVector(colors)
    return pcd

我个人习惯用灰度表示强度,因为人眼对灰度变化比较敏感。当然你也可以用彩色映射,比如低强度用蓝色、高强度用红色,看个人喜好。

4.2.3 按分类着色

如果你手头有标注好的点云(比如 Semantic3D、SemanticKITTI 数据集),每个点都有一个分类标签。这时候着色就变成了语义可视化。

# 定义分类颜色映射(SemanticKITTI 风格)
class_colors = {
    0: [0, 0, 0],       # 未标注
    1: [100, 150, 245], # 汽车
    2: [100, 230, 245], # 自行车
    3: [30, 60, 150],   # 行人
    4: [80, 30, 180],   # 道路
    5: [0, 0, 230],     # 停车位
    6: [255, 30, 30],   # 人行道
    7: [255, 40, 200],  # 建筑物
    8: [150, 30, 90],   # 植被
    9: [255, 150, 0],   # 杆状物
}

def color_by_class(pcd, labels):
    points = np.asarray(pcd.points)
    n = len(points)
    colors = np.zeros((n, 3))
    
    for cls, rgb in class_colors.items():
        mask = (labels == cls)
        colors[mask] = np.array(rgb) / 255.0
    
    pcd.colors = o3d.utility.Vector3dVector(colors)
    return pcd

避坑指南:我曾经在标注数据里发现分类标签有缺失值(-1),结果着色时直接报错。后来我加了一行 labels[labels < 0] = 0,把无效标签统一归到「未标注」类。

4.3 添加坐标轴与网格

没有坐标轴和网格的点云,就像没有标尺的地图——你知道有东西,但不知道它在哪里、有多大。坐标轴帮你建立方向感,网格帮你估计距离。

4.3.1 添加坐标轴

Open3D 自带了一个 create_mesh_coordinate_frame 函数,可以生成一个三色坐标轴。红色是 X 轴,绿色是 Y 轴,蓝色是 Z 轴。

# 创建坐标轴,size 控制长度
axis = o3d.geometry.TriangleMesh.create_coordinate_frame(size=2.0, origin=[0, 0, 0])

# 和点云一起显示
o3d.visualization.draw_geometries([pcd, axis])

如果你觉得默认的坐标轴太细,看不清楚,可以自己画一个粗一点的版本。我有时候会画一个带箭头的坐标轴,用圆柱体加圆锥体组合:

def create_arrow(origin, direction, length=1.0, radius=0.05):
    """创建一个箭头(圆柱 + 圆锥)"""
    # 圆柱部分
    cylinder = o3d.geometry.TriangleMesh.create_cylinder(radius=radius, height=length*0.8)
    # 圆锥部分
    cone = o3d.geometry.TriangleMesh.create_cone(radius=radius*2, height=length*0.2)
    
    # 平移和旋转
    # ...(这里省略具体变换代码,实际项目中用矩阵乘法)
    return cylinder + cone

4.3.2 添加网格

网格相当于一个地面参考。我常用的有两种:平面网格和立体网格。

平面网格适合放在地面高度,帮你判断物体的水平位置:

def create_ground_grid(size=10, step=1.0):
    """创建地面网格"""
    lines = []
    points = []
    
    # 生成网格线
    half = size / 2
    for i in range(-int(half), int(half) + 1):
        # X 方向线
        points.append([i * step, -half, 0])
        points.append([i * step, half, 0])
        lines.append([len(points)-2, len(points)-1])
        # Y 方向线
        points.append([-half, i * step, 0])
        points.append([half, i * step, 0])
        lines.append([len(points)-2, len(points)-1])
    
    line_set = o3d.geometry.LineSet()
    line_set.points = o3d.utility.Vector3dVector(points)
    line_set.lines = o3d.utility.Vector2iVector(lines)
    line_set.paint_uniform_color([0.5, 0.5, 0.5])  # 灰色网格
    return line_set

grid = create_ground_grid(size=20, step=1.0)
o3d.visualization.draw_geometries([pcd, axis, grid])

实用建议:网格的 step 大小要根据你的点云尺度来定。如果是室内场景,step=0.5m 就够了;如果是城区场景,step=5m 更合适。我一般先看一次点云的范围,再决定网格参数。

4.4 本章知识体系

下面这张图把本章的核心内容串起来了。你可以把它当作一个速查地图:

点云可视化进阶知识体系 点云可视化进阶 多窗口可视化 独立视角 vs 联动视角 Visualizer 类 + 渲染循环 点云着色 按高度:Z 值 → colormap 按强度:反射值 → 灰度/彩色 按分类:标签 → 语义颜色映射 坐标轴与网格 坐标轴:方向参考(RGB) 网格:距离参考(平面/立体) 实用组合建议 • 调试算法时:多窗口(前/俯/侧)+ 按高度着色 + 坐标轴 • 标注数据时:单窗口 + 按分类着色 + 网格 • 展示成果时:单窗口 + 按强度着色 + 坐标轴 + 网格

4.5 综合示例:一个完整的可视化工具

最后,我把上面所有功能打包成一个函数。你以后做项目可以直接拿来用:

def visualize_pointcloud(pcd, mode='height', multi_window=False, show_axis=True, show_grid=True):
    """
    点云可视化综合函数
    
    参数:
        pcd: open3d.geometry.PointCloud
        mode: 'height' | 'intensity' | 'class'
        multi_window: 是否开启多窗口
        show_axis: 是否显示坐标轴
        show_grid: 是否显示网格
    """
    # 1. 着色
    if mode == 'height':
        pcd = color_by_height(pcd)
    elif mode == 'intensity':
        pcd = color_by_intensity(pcd)
    elif mode == 'class':
        # 假设 labels 是外部传入的
        pass
    
    # 2. 准备辅助元素
    geometries = [pcd]
    if show_axis:
        axis = o3d.geometry.TriangleMesh.create_coordinate_frame(size=2.0)
        geometries.append(axis)
    if show_grid:
        grid = create_ground_grid(size=20, step=1.0)
        geometries.append(grid)
    
    # 3. 显示
    if multi_window:
        # 多窗口逻辑(略,参考 4.1 节)
        pass
    else:
        o3d.visualization.draw_geometries(geometries)

我的习惯:我把这个函数放在我的 utils.py 里,每次加载点云后直接调用。调试时用 mode='height' 看地形,评估分割结果时用 mode='class' 看语义,效率高很多。