资讯动态

NuScenes点云可视化与交互式框选实战:从bin文件到目标提取全流程

发布时间:2026/10/20 20:54:05 来源:尧图企业网站定制
第一次拿到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, dtypenp.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(versionv1.0-mini, dataroot./data/nuscenes, verboseFalse) 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 python3.8 -y conda activate nuscenes-vis pip install nuscenes-devkit open3d numpy matplotlib为什么建议Python 3.8nuscenes-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(versionv1.0-mini, dataroot./data/nuscenes, verboseTrue)看到打印出数据版本和路径信息就说明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, dtypenp.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_nameNuScenes 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 ], axis1) 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, nsweeps5) points pc.points.T # 转成 (N, 5) pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(points[:, :3])nsweeps5表示加上当前帧往前数共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], s0.1, cgray, alpha0.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), dtypebool) 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], s5, cred) plt.draw() print(f当前选中点数: {selected_mask.sum()}) rs RectangleSelector(ax, on_select, useblitTrue, button[1], minspanx0, minspany0) 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], axis1) ) 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_okTrue) 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鼠标左键框选点CtrlC复制选中的点索引关闭窗口后在代码里取回。# 仅在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_size0.2)体素尺寸0.2米对可视化足够点数量能降到原来的十分之一甚至更少。第二个是距离过滤只渲染以自车为中心半径50米内的点dist np.linalg.norm(points[:, :2], axis1) 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索引、传感器通道、目标类别列表跑实验时反复调用它检查数据质量能省下大量重复劳动。这也是所有预标注工具的最初雏形值得亲手写一遍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑