☰
NuScenes点云可视化与交互式框选实战:从bin文件到目标提取全流程
2026/10/20 20:53:54 网站建设 项目流程

第一次拿到NuScenes的激光雷达数据时,我盯着屏幕上的二进制bin文件发愣——一堆float32拼成的点云,没有任何可视化结果,根本看不出场景里有什么。后来花了大半天把点云渲染到3D窗口、把标注框叠加进去、再做了交互式框选,才算真正搞清楚这个数据集该怎么用。这篇博文就是把这条完整链路写下来:从nuscenes读取点云、用Open3D和matplotlib做可视化、按类别过滤标注框、再到交互式框选目标点云。适合刚接触NuScenes数据集、想快速做数据探索或预标注的同学,也适合需要从点云里提取特定目标点做下游算法验证的人。

1. 开始之前:先看清NuScenes激光雷达数据的"家底"

1.1 点云bin文件里到底存了什么

NuScenes的LIDAR_TOP主激光雷达数据不是常见的pcd或ply格式,而是bin格式。很多人第一次接触时直接用np.load读,结果报错,原因就在于bin是纯二进制原始存储,没有文件头,必须用np.fromfile按指定dtype读取。

import numpy as np points = np.fromfile(lidar_path, dtype=np.float32).reshape(-1, 5)

读出来是一个(N, 5)的数组,N是这一帧的点数,通常在一两万这个量级。每一行5个值依次是:

  • x、y、z:激光雷达坐标系下的三维坐标,单位是米。以车辆自身为原点,x朝前,y朝左,z朝上。
  • intensity:反射强度。反映目标表面对激光的反射能力,距离越远衰减越明显。
  • ring_index:这条点来自哪一条激光线束。32线雷达就是0到31,这个值在点云去噪、地面分割时非常有用,视觉上也可以用它给不同线束染色。

理解了这5列的含义,后续做颜色映射、范围过滤、多帧拼接就有抓手了。很多教程直接拿前3列当点云用,后面两列就扔掉了,其实intensity对区分车牌、道路标线、金属车身特别有帮助,后面我会具体讲怎么用它做颜色渲染。

1.2 标注框和类别信息藏在哪

NuScenes的标注体系分三层:sample(一个时刻的关键帧)、sample_data(该时刻某个传感器的数据)、sample_annotation(该时刻所有目标的3D标注框)。一个sample里通过data字段挂载LIDAR_TOP、CAM_FRONT等传感器数据;而3D框存在独立的标注表里。

最方便的读取接口是nusc.get_sample_data(),它会一次性返回点云路径、当前传感器坐标系下的所有3D框列表、以及相机内参(如果传感器是相机的话):

from nuscenes.nuscenes import NuScenes nusc = NuScenes(version='v1.0-mini', dataroot='./data/nuscenes', verbose=False) sample = nusc.sample[3] lidar_token = sample['data']['LIDAR_TOP'] lidar_path, boxes, _ = nusc.get_sample_data(lidar_token)

每个box对象有一个name字段,类别字符串类似vehicle.car、human.pedestrian、bicycle、traffic_cone。要筛选"所需类",本质就是对这个字段做匹配。"vehicle"大类下还有car、truck、bus、motorcycle等小类,按需选择前缀即可。

注意:get_sample_data返回的boxes已经是变换到激光雷达坐标系下的。如果自己去读sample_annotation再画框,那是全局坐标系,需要先做ego pose和传感器外参变换,否则框会出现在完全错误的位置。

2. 环境搭建与数据准备:从零跑通NuScenes工具链

2.1 依赖安装与版本选择

可视化这条链路涉及的库不算多,但版本组合很关键,尤其是Open3D。我踩过的组合是Python 3.8 + nuscenes-devkit + Open3D 0.13,跑得很稳。

conda create -n nuscenes-vis python=3.8 -y conda activate nuscenes-vis pip install nuscenes-devkit open3d numpy matplotlib

为什么建议Python 3.8?nuscenes-devkit对高版本Python的部分接口存在兼容性警告,3.8环境最省心。Open3D建议装0.12到0.14之间的版本,这个区间还保留了部分交互可视化API;如果你只是做静态渲染,新版本也能用,但后面涉及立体框选的代码可能报错,到时候按我第4.4节的方案回退就行。

2.2 mini数据集的下载与目录结构

可视化演示不需要下载几百GB的完整数据,用官方v1.0-mini就够。mini版包含十几个场景,从波士顿和新加坡两个城市采样,覆盖城市道路、十字路口等典型自动驾驶场景,体积比完整版小得多。

下载解压后目录长这样:

nuscenes/ ├── v1.0-mini/ │ ├── maps/ │ ├── samples/ │ ├── sweeps/ │ └── v1.0-mini/
  • samples:关键帧数据,也就是每个sample对应的传感器文件,激光雷达点云在LIDAR_TOP子目录下。
  • sweeps:关键帧之间的中间帧,多帧叠加时主要用这里的数据。
  • v1.0-mini:所有元数据和标注的json文件。
  • maps:高清地图栅格图,做可视化时通常用不到。

初始化时会做数据版本校验:

nusc = NuScenes(version='v1.0-mini', dataroot='./data/nuscenes', verbose=True)

看到打印出数据版本和路径信息,就说明dataroot指对了。最常见的坑是把dataroot指到了v1.0-mini这一层,正确做法是指向包含v1.0-mini目录的上级目录。

3. 可视化第一站:把单帧点云和3D框同时画出来

3.1 加载点云并渲染成Open3D窗口

点位数据读取后,最直接的渲染方式是Open3D。把点云坐标塞进PointCloud对象,然后给每个点设置颜色,就能在窗口中拖动、缩放、旋转查看。

颜色这里我建议优先用高度z做伪彩色映射,低处偏蓝、高处偏红。因为自动驾驶场景里地面、车辆顶部、行人头部的高度层次分明,伪彩色比单一强度灰度更容易看清结构。

import os import numpy as np import open3d as o3d def load_lidar_points(nusc, sample): lidar_data = nusc.get('sample_data', sample['data']['LIDAR_TOP']) lidar_path = os.path.join(nusc.dataroot, lidar_data['filename']) return np.fromfile(lidar_path, dtype=np.float32).reshape(-1, 5) points = load_lidar_points(nusc, sample) # 按z轴高度做伪彩色 z = points[:, 2] z_min, z_max = z.min(), z.max() colors = np.zeros((len(z), 3)) for i in range(len(z)): t = (z[i] - z_min) / (z_max - z_min + 1e-6) colors[i] = [t, 0.5 * (1 - abs(2 * t - 1)), 1 - t] pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points[:, :3]) pcd.colors = o3d.utility.Vector3dVector(colors) o3d.visualization.draw_geometries([pcd], window_name='NuScenes LIDAR single frame')

用for循环给每个点算颜色在点云量大的时候有点慢,实际项目中可以改成向量化计算:

t = (z - z_min) / (z_max - z_min + 1e-6) colors = np.stack([ t, 0.5 * (1 - np.abs(2 * t - 1)), 1 - t ], axis=1) pcd.colors = o3d.utility.Vector3dVector(colors)

这样几万点也是毫秒级完成。

3.2 把标注框画进点云里

光看裸点云,人眼很难判断一团点到底是车还是树。要把3D标注框叠加到点云上,就需要把box的几何信息转换成Open3D的LineSet线框。

NuScenes的box有三个关键属性:

  • center:框中心坐标,三元组。
  • wlh:尺寸三元组,顺序是(width, length, height)。width对应y轴方向,length对应x轴方向,这个顺序特别容易搞反。
  • orientation:四元数表示的朝向,用orientation.rotation_matrix可取旋转矩阵。

生成8个顶点再连接成12条边的完整代码:

def box_to_vertices(box): w, l, h = box.wlh local_corners = np.array([ [l / 2, w / 2, h / 2], [l / 2, -w / 2, h / 2], [-l / 2, -w / 2, h / 2], [-l / 2, w / 2, h / 2], [l / 2, w / 2, -h / 2], [l / 2, -w / 2, -h / 2], [-l / 2, -w / 2, -h / 2], [-l / 2, w / 2, -h / 2], ]) R = box.orientation.rotation_matrix return local_corners @ R.T + box.center def create_bbox_lineset(box, color=(0.0, 1.0, 0.0)): verts = box_to_vertices(box) lines = [ [0, 1], [1, 2], [2, 3], [3, 0], # 顶面 [4, 5], [5, 6], [6, 7], [7, 4], # 底面 [0, 4], [1, 5], [2, 6], [3, 7], # 垂直线 ] line_set = o3d.geometry.LineSet() line_set.points = o3d.utility.Vector3dVector(verts) line_set.lines = o3d.utility.Vector2iVector(lines) line_set.colors = o3d.utility.Vector3dVector([color] * len(lines)) return line_set

渲染时把所有框的LineSet和点云一起传进去:

geometries = [pcd] _, boxes, _ = nusc.get_sample_data(lidar_token) for box in boxes: geometries.append(create_bbox_lineset(box)) o3d.visualization.draw_geometries(geometries)

第一眼看到点云和框叠在一起,你会立刻理解"这个数据集质量很高"这句话的含义——每辆车的框刚好把点云轮廓包住。如果看到某个框歪了90度,十有八九是我上面说到的wlh顺序搞错了。

3.3 只显示"需要的类":按类别过滤的渲染

框选之前,先学会按类别筛选渲染。比如只想看车辆和行人,就遍历boxes,根据box.name前缀做区分,不同类别用不同颜色:

geometries = [pcd] for box in boxes: if box.name.startswith('vehicle'): geometries.append(create_bbox_lineset(box, color=(1.0, 0.3, 0.2))) elif box.name.startswith('human.pedestrian'): geometries.append(create_bbox_lineset(box, color=(0.2, 0.8, 0.2)))

还可以顺手统计类别分布:

from collections import Counter name_counter = Counter(box.name for box in boxes) print(name_counter)

输出类似{'vehicle.car': 6, 'human.pedestrian': 3, 'vehicle.truck': 1},方便了解当前帧里有哪些目标、各类别数量。这个静态过滤版本其实就是"框选所需类"的可视化前置,先把关注类别全部显示出来,再动手做交互框选。

3.4 多帧点云叠加,看清目标全貌

单帧点云大约只有两万个点,远处目标往往只有零星几个点,轮廓非常模糊。解决办法是把前后若干帧(sweeps)叠加到当前帧坐标系下,点密度立刻提升。NuScenes官方工具类LidarPointCloud提供了多帧拼接接口,内部已经处理好位姿变换:

from nuscenes.utils.data_classes import LidarPointCloud pc, _ = LidarPointCloud.from_file_multisweep(nusc, sample, 'LIDAR_TOP', nsweeps=5) points = pc.points.T # 转成 (N, 5) pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points[:, :3])

nsweeps=5表示加上当前帧往前数共5帧。叠加后目标看起来更"实",但要注意动态物体会出现轻微拖影,因为车在两帧之间已经移动了。做可视化演示完全没问题,如果做精确标注,还是得回到单帧上看。

4. 交互式框选"所需类"的完整方案

4.1 方案取舍:为什么首选BEV平面框选

"框选所需类"本质是交互式地从点云里提取某个类别目标的点。常见的三种做法:

方案实现成本稳定性适用场景
matplotlib BEV矩形框选低高快速提取ROI、目标粗筛
Open3D立体框选中受版本影响大需要三维精确选点
3D框内过滤(后处理)中高精标注、预标注

自动驾驶点云目标在鸟瞰图上前后左右边界非常清晰,车辆、行人、骑行者在BEV投影下都有明确的轮廓,这和人眼在俯视图里分辨街景的逻辑一致。高度方向信息在BEV里虽然会压缩掉,但对"提取某个目标区域"这个需求来说影响不大——大多数时候你框的是xy平面的区域,z轴保留完整高度即可。

所以我首选matplotlib做BEV矩形框选:实现简单、跨版本稳定、代码五分钟能跑通。需要更高精度再切换到3D框过滤。

4.2 BEV框选代码与交互

完整代码如下。我加了多次框选累加的逻辑:用布尔mask记录选中的点,每次拖拽矩形就在原始mask上做或运算,框选区域实时红色高亮。

import matplotlib.pyplot as plt from matplotlib.widgets import RectangleSelector # 复用前面加载的points fig, ax = plt.subplots(figsize=(12, 12)) ax.scatter(points[:, 0], points[:, 1], s=0.1, c='gray', alpha=0.5) ax.set_title('BEV - 按住鼠标左键拖拽框选,可多次框选') ax.set_xlabel('x (m)') ax.set_ylabel('y (m)') ax.set_aspect('equal') selected_mask = np.zeros(len(points), dtype=bool) def on_select(eclick, erelease): global selected_mask x1, y1 = eclick.xdata, eclick.ydata x2, y2 = erelease.xdata, erelease.ydata x_min, x_max = sorted([x1, x2]) y_min, y_max = sorted([y1, y2]) mask = ( (points[:, 0] >= x_min) & (points[:, 0] <= x_max) & (points[:, 1] >= y_min) & (points[:, 1] <= y_max) ) selected_mask |= mask # 用红色点重新绘制已选区域 ax.scatter(points[selected_mask, 0], points[selected_mask, 1], s=5, c='red') plt.draw() print(f'当前选中点数: {selected_mask.sum()}') rs = RectangleSelector(ax, on_select, useblit=True, button=[1], minspanx=0, minspany=0) plt.show()

运行后先显示整帧点云,鼠标左键拖出一个矩形,框内点变成红色。可以连续框选多个目标,比如先框一辆车,再框一个行人,两个区域的点都会被累积到selected_mask里。全部框选完直接关闭窗口,selected_mask就是最终结果。

如果你发现矩形框的朝向是轴对齐的,而目标本身是有旋转角度的,框进去的点会比实际目标多出一些角落区域。这是BEV矩形框的天然局限,后续可以用聚类或3D框过滤来剔除。

4.3 框选结果的保存、坐标还原与类别映射

框选完成后的第一件事是保存。我通常把选中点保存成两份:一份npy保留原始5列数据,一份ply方便用Open3D直接查看。

final_points = points[selected_mask] np.save('roi_points.npy', final_points) # 保存为ply,方便后续在Open3D里反复查看 pcd_selected = o3d.geometry.PointCloud() pcd_selected.points = o3d.utility.Vector3dVector(final_points[:, :3]) # 用强度做简单灰度颜色渲染 intensity = final_points[:, 3] vmin, vmax = np.percentile(intensity, [2, 98]) gray = np.clip((intensity - vmin) / (vmax - vmin + 1e-6), 0, 1) pcd_selected.colors = o3d.utility.Vector3dVector( np.stack([gray, gray, gray], axis=1) ) o3d.io.write_point_cloud('roi_points.ply', pcd_selected)

如果需要把框选结果和具体类别绑定,最简单的方式是框选完在终端输入类别名,再配合一个计数器实现分文件夹存储:

import os save_dir = 'dataset/' category = input('请输入目标类别(如 vehicle.car): ').strip() class_dir = os.path.join(save_dir, category.replace('.', '_')) os.makedirs(class_dir, exist_ok=True) counter = len(os.listdir(class_dir)) np.save(os.path.join(class_dir, f'{counter:06d}.npy'), final_points) print(f'已保存到 {class_dir}/{counter:06d}.npy')

这样一轮下来就能得到按类别分好的点云文件,而且每条数据还保留了intensity和ring_index,后续做特征提取时不会缺信息。注意保存的坐标仍然是以激光雷达为中心的世界坐标,绝对值没有截断,目标周围的背景点也还在。如果需要严格的目标点集,还要做一次3D框内过滤。

4.4 进阶:用Open3D做立体框选

如果BEV矩形框满足不了你,希望直接在三维空间里框选一段点云,可以尝试Open3D自带的顶点选择功能。在Open3D 0.12到0.14版本里,可以用draw_geometries_with_vertex_selection打开窗口,按住Ctrl+鼠标左键框选点,Ctrl+C复制选中的点索引,关闭窗口后在代码里取回。

# 仅在Open3D 0.12-0.14版本可用 if hasattr(o3d.visualization, 'draw_geometries_with_vertex_selection'): o3d.visualization.draw_geometries_with_vertex_selection([pcd]) selected = o3d.visualization.get_vertex_selection() indices = [idx for idx, _ in selected] selected_pcd = pcd.select_by_index(indices)

这套交互在旧版GUI里实际用起来略有点反直觉,而且新版Open3D去掉或改动了不少GUI API,很容易报AttributeError。我自己的建议是:不要依赖这个接口做核心流程,把BEV框选作为主线,Open3D的顶点选择作为偶尔看一眼的辅助工具。

更贴近真实标注场景的做法是半自动3D框过滤。有了目标的位置和尺寸先验,用代码把指定box内部的点精确提取出来。假设目标yaw已知,绕z轴旋转后的局部坐标判断点是否在框内:

def filter_points_in_box(points, center, wlh, yaw): w, l, h = wlh cos_yaw, sin_yaw = np.cos(yaw), np.sin(yaw) # 将点云平移后旋转到目标局部坐标系 rot = np.array([ [cos_yaw, sin_yaw, 0], [-sin_yaw, cos_yaw, 0], [0, 0, 1] ]) local = (points[:, :3] - np.array(center)) @ rot mask = ( (np.abs(local[:, 0]) <= l / 2) & (np.abs(local[:, 1]) <= w / 2) & (np.abs(local[:, 2]) <= h / 2) ) return mask

这个函数的实际业务价值很高:在BEV框选出目标大致区域后,再用目标检测框或人工指定的尺寸做精确过滤,得到的就是"只包含该目标表面点"的干净点云,直接可以作为分类网络、点云补全或预标注的输入。

5. 踩坑清单与性能优化建议

5.1 三个最容易搞错的坐标系与尺寸

NuScenes里关于坐标和尺寸的坑,我几乎每轮都踩一遍。

第一是wlh顺序。box.wlh返回的是(width, length, height),width对应y轴,length对应x轴。如果你按习惯写成(length, width, height),框会整体旋转90度。在BEV图上一辆横向停靠的车会变成纵向,视觉上极其反直觉。

第二是传感器坐标系和全局坐标系的混淆。前面强调过,get_sample_data返回的boxes已经变换到当前传感器坐标系下,直接画没问题。但你如果为了做数据增强,自己从sample_annotation取框再拼回去,就必须依次应用calibrated_sensor外参和ego_pose位姿变换。漏一步,框就飞到地图另一侧。

第三是旋转矩阵的方向。NuScenes的orientation.rotation_matrix能把局部坐标变换到全局坐标。如果我是把世界点反向映射到局部坐标做过滤,记得用local_corners @ R.T或者对旋转矩阵做转置后再乘,方向反了过滤器会把目标外面的点全选进来。简单验证方法是选一个已知朝向的框,检查局部坐标下车辆长轴是否落在x轴方向。

5.2 点云渲染卡顿的优化

点云量一大,Open3D窗口拖拽掉帧是常态。最常见的原因是整帧几十万点全画出来了,而显示区域其实只有几十米见方。两个优化手段建议叠加使用:

第一个是体素下采样:

pcd_down = pcd.voxel_down_sample(voxel_size=0.2)

体素尺寸0.2米对可视化足够,点数量能降到原来的十分之一甚至更少。

第二个是距离过滤,只渲染以自车为中心半径50米内的点:

dist = np.linalg.norm(points[:, :2], axis=1) mask = dist < 50 points = points[mask]

远处点本身稀疏且信息量低,做可视化时完全可以直接丢弃。这两步叠加后,拖拽旋转会流畅很多,甚至可以用更大的窗口渲染。

5.3 intensity颜色映射的离群点问题

用intensity做灰度渲染时,如果直接min-max归一化,结果往往是一团黑或一团白。原因是场景里偶尔出现高强度反射点,比如反光车牌、金属护栏,它们的intensity远高于其他点,把归一化的分母拉得很大,普通点全部被压到接近0。

正确的做法是先做百分位截断,再归一化:

intensity = points[:, 3] vmin, vmax = np.percentile(intensity, [2, 98]) intensity_norm = np.clip((intensity - vmin) / (vmax - vmin + 1e-6), 0, 1)

这样2%以下的低强度噪声和2%以上的高亮离群点都被压平,中间95%以上点的层次感完全保留。同样的思路也适用于高度z的伪彩色映射,只不过z的离群点问题没那么严重。

5.4 从框选结果到训练样本集的思考

框选出来的点云不等于最终标签,它只是ROI。我在实际项目里通常还要接三步处理:

第一步,对ROI点云做DBSCAN聚类,把目标表面点和杂散背景点分开。因为BEV矩形框容易把旁边绿化带、路沿的点也框进来,聚类后只保留点数量最多的那个簇。

第二步,用类别先验尺寸过滤。车子长宽高大概4.5米x1.8米x1.5米,行人约0.7米x0.5米x1.7米。按第4.4节的filter_points_in_box方法,用聚类结果的质心和类别先验尺寸生成一个临时3D框,保留框内点,能进一步去掉背景。

第三步,按统一的目录结构存储。不要把所有npy堆在一个文件夹,建议按类别分目录,每个npy文件旁边配一个同名json记录类别、目标中心、原始帧token等元信息。这样后续做检测、分类或者生成预标注文件时,数据追溯非常方便。

写在最后:个人实操中的一点体会

这套可视化加框选的流程我前后跑了很多次,最大的体会是:可视化不是可有可无的锦上添花,而是理解数据的最快路径。很多算法效果不如预期,追根溯源其实是对数据本身的结构理解不够——比如某个类别在激光雷达点云里天然只有几十个点,那后期再怎么调模型上限也有限。如果遇到某个框画出来角度怪怪的,别急着怀疑算法,先检查wlh顺序和旋转方向,这两个点占了六成以上的问题。最后分享一个小技巧:把整套流程封装成类,参数化成scene索引、传感器通道、目标类别列表,跑实验时反复调用它检查数据质量,能省下大量重复劳动。这也是所有预标注工具的最初雏形,值得亲手写一遍。

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

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

立即咨询