Python实现NuScenes数据集可视化:从3D边界框到2D图像的投影原理与实践
一、 背景介绍
NuScenes数据集是自动驾驶领域的重要数据集之一,包含多个场景的多传感器数据(摄像头、激光雷达等),以及详细的3D物体标注(车辆、行人、障碍物等)。数据可视化是理解和分析自动驾驶数据集的关键第一步。
为什么需要可视化?
- 理解数据分布:直观查看不同场景中的物体分布
- 验证标注质量:检查3D标注框是否准确贴合物体
- 算法调试:验证感知算法的输出结果
- 研究起点:为后续的目标检测、语义分割等任务提供基础
技术挑战
将3D空间中的物体位置投影到2D图像上涉及复杂的坐标变换,需要理解:
- 世界坐标系 → 车辆坐标系
- 车辆坐标系 → 相机坐标系
- 相机坐标系 → 图像坐标系
本文将详细解释这些变换的原理,并提供完整的Python实现代码。
二、准备工作
2.1 下载数据集
# 下载mini数据集(3.9GB)
wget -O v1.0-mini.tgz https://www.nuscenes.org/data/v1.0-mini.tgz
# 创建目录并解压
mkdir nuscenes
tar -xf v1.0-mini.tgz -C nuscenes
2.2 数据集目录结构
解压后的目录结构如下:
root@in-dev-docker:/apollo# tree -L 2 nuscenes/
nuscenes/
|-- maps # 高清地图文件
| |-- 36092f0b03a857c6a3403e25b4b7aab3.png
| |-- 37819e65e09e5547b8a3ceaefba56bb2.png
| |-- 53992ee3023e5494b90c316c183be829.png
| `-- 93406b464a165eaba6d9de76ca09f5da.png
|-- samples # 关键帧传感器数据
| |-- CAM_BACK
| |-- CAM_BACK_LEFT
| |-- CAM_BACK_RIGHT
| |-- CAM_FRONT
| |-- CAM_FRONT_LEFT
| |-- CAM_FRONT_RIGHT
| |-- LIDAR_TOP # 激光雷达点云
| |-- RADAR_BACK_LEFT
| |-- RADAR_BACK_RIGHT
| |-- RADAR_FRONT
| |-- RADAR_FRONT_LEFT
| `-- RADAR_FRONT_RIGHT
|-- sweeps # 非关键帧传感器数据
| |-- CAM_BACK
| |-- CAM_BACK_LEFT
| |-- CAM_BACK_RIGHT
| |-- CAM_FRONT
| |-- CAM_FRONT_LEFT
| |-- CAM_FRONT_RIGHT
| |-- LIDAR_TOP
| |-- RADAR_BACK_LEFT
| |-- RADAR_BACK_RIGHT
| |-- RADAR_FRONT
| |-- RADAR_FRONT_LEFT
| `-- RADAR_FRONT_RIGHT
`-- v1.0-mini # 标注文件
|-- attribute.json
|-- calibrated_sensor.json # 传感器标定参数
|-- category.json
|-- ego_pose.json
|-- instance.json
|-- log.json
|-- map.json
|-- sample.json # 样本信息
|-- sample_annotation.json # 3D标注
|-- sample_data.json
|-- scene.json
|-- sensor.json
`-- visibility.json
28 directories, 17 files
三、 技术流程
3.1 整体可视化流程
3.2 核心原理:3D到2D投影
坐标系转换流程
实际场景示例
假设:
- 车辆位置:(10, 5, 0) 世界坐标
- 相机安装位置:(1.5, 0, 1.7) 车辆坐标
- 物体位置:(12, 6, 0) 世界坐标
转换过程:
- 世界→车辆:
(12,6,0) - (10,5,0) = (2,1,0) // 物体在车辆前方2米,右侧1米
- 车辆→相机:
(2,1,0) - (1.5,0,1.7) = (0.5,1,-1.7) // 物体在相机右前方0.5米,右侧1米,下方1.7米
投影公式
3D点 (X, Y, Z) 投影到2D图像点 (u, v) 的公式为:
u = f_x * (X/Z) + c_x
v = f_y * (Y/Z) + c_y
其中:
f_x
,f_y
:焦距参数,决定放大倍数c_x
,c_y
:主点偏移,将坐标系中心移到图像中心X/Z
,Y/Z
:透视除法,实现"近大远小"效果
投影过程图解
四、 代码实现详解
4.1 环境安装
pip install opencv-python==4.5.5.64
pip install nuscenes-devkit matplotlib
4.2 完整代码
cat > visualize_nuscenes.py <<-'EOF'
import os
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer,encoding='utf-8')
import cv2
import numpy as np
from nuscenes.nuscenes import NuScenes
from nuscenes.utils.data_classes import Box
from nuscenes.utils.geometry_utils import view_points
from pyquaternion import Quaternion
import time
# 初始化设置
DATASET_PATH = 'nuscenes' # 数据集路径
VERSION = 'v1.0-mini' # 使用mini数据集
SAMPLE_INDEX = 10 # 选择要可视化的样本索引
CAMERA_NAME = 'CAM_FRONT' # 要使用的摄像头名称
OUTPUT_PATH = 'nuscenes_3d_boxes.jpg' # 输出图像路径
def main():
# 记录开始时间
start_time = time.time()
# 初始化NuScenes对象
print(f"加载NuScenes数据集: {VERSION}...")
nusc = NuScenes(version=VERSION, dataroot=DATASET_PATH, verbose=True)
print(f"数据集加载完成,耗时: {time.time() - start_time:.2f}秒")
# 获取样本数据
print(f"\n处理样本索引: {SAMPLE_INDEX}...")
sample_token = nusc.sample[SAMPLE_INDEX]['token']
sample = nusc.get('sample', sample_token)
# 获取相机数据
print(f"获取相机数据: {CAMERA_NAME}...")
cam_data = nusc.get('sample_data', sample['data'][CAMERA_NAME])
image_path = os.path.join(nusc.dataroot, cam_data['filename'])
# 加载图像
print(f"加载图像: {image_path}...")
image = cv2.imread(image_path)
if image is None:
print(f"错误: 无法加载图像 {image_path}")
return
# 获取相机内参和变换矩阵
print("获取相机校准信息...")
camera_calib = nusc.get('calibrated_sensor', cam_data['calibrated_sensor_token'])
intrinsic = np.array(camera_calib['camera_intrinsic'])
print(f"相机内参矩阵:\n",intrinsic)
# 获取车辆位姿
print("获取车辆位姿信息...")
ego_pose = nusc.get('ego_pose', cam_data['ego_pose_token'])
rotation = Quaternion(ego_pose['rotation']).rotation_matrix
translation = np.array(ego_pose['translation'])
print(f"车辆位姿(rotation):\n",rotation)
print(f"车辆位姿(translation):\n",translation)
# 获取所有标注
print("获取3D标注信息...")
ann_tokens = sample['anns']
annotations = [nusc.get('sample_annotation', token) for token in ann_tokens]
# 可视化3D标注框
print("可视化3D边界框...")
visualize_3d_boxes(image, annotations, intrinsic, rotation, translation, camera_calib)
# 添加标题
title = f"NuScenes 3D Boxes: {CAMERA_NAME} - Sample {SAMPLE_INDEX}"
cv2.putText(image, title, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
# 保存结果
print(f"\n保存结果到: {OUTPUT_PATH}")
cv2.imwrite(OUTPUT_PATH, image)
# 显示图像
#cv2.imshow(title, image)
#cv2.waitKey(0)
#cv2.destroyAllWindows()
# 打印总耗时
total_time = time.time() - start_time
print(f"\n处理完成! 总耗时: {total_time:.2f}秒")
def visualize_3d_boxes(image, annotations, intrinsic, rotation, translation, camera_calib):
"""
在图像上可视化3D边界框
:param image: 输入图像
:param annotations: 标注列表
:param intrinsic: 相机内参矩阵
:param rotation: 车辆旋转矩阵
:param translation: 车辆平移向量
:param camera_calib: 相机校准信息
"""
# 设置类别颜色映射
color_map = {
'vehicle': (0, 0, 255), # 红色: 车辆
'human': (0, 255, 0), # 绿色: 行人
'movable': (255, 0, 0), # 蓝色: 可移动物体
'static': (255, 255, 0) # 青色: 静态物体
}
# 统计不同类别的数量
category_count = {}
# 绘制所有3D边界框
for i, ann in enumerate(annotations):
# 确定类别颜色
box_color = (200, 200, 200) # 默认灰色
for cat in color_map:
if cat in ann['category_name']:
box_color = color_map[cat]
break
# 创建3D边界框
box = Box(ann['translation'], ann['size'], Quaternion(ann['rotation']))
# 将3D框投影到图像平面
points_2d, visible = project_3d_box_to_image(i,box, intrinsic, rotation, translation, camera_calib)
# 绘制3D框
if visible:
draw_3d_box(image, points_2d, box_color, ann['category_name'])
# 更新类别计数
category = ann['category_name'].split('.')[-1]
category_count[category] = category_count.get(category, 0) + 1
# 打印统计信息
print(f"\n检测到 {len(annotations)} 个标注:")
for category, count in category_count.items():
print(f" {category}: {count}")
def project_3d_box_to_image(index,box, intrinsic, rotation, translation, camera_calib):
# 获取3D框的8个角点 (3x8)
corners = box.corners()
# 变换到车辆坐标系
corners = corners - translation.reshape(3, 1)
corners = np.dot(rotation.T, corners)
# 变换到相机坐标系
cam_translation = np.array(camera_calib['translation'])
cam_rotation = Quaternion(camera_calib['rotation']).rotation_matrix
if index==0:
print(f"cam_translation:\n{cam_translation}")
print(f"cam_rotation:\n{cam_rotation}")
corners = corners - cam_translation.reshape(3, 1)
corners = np.dot(cam_rotation.T, corners)
# 使用NumPy和OpenCV实现投影
# 1. 将3D点转换为齐次坐标 (4x8)
homogeneous_points = np.vstack((corners, np.ones((1, corners.shape[1]))))
# 2. 应用相机内参矩阵
# 将内参矩阵扩展为3x4矩阵(添加零列)
intrinsic_3x4 = np.hstack((intrinsic, np.zeros((3, 1))))
# 3. 投影到图像平面
projected = np.dot(intrinsic_3x4, homogeneous_points)
if index==0:
print(f"corners:\n{corners}")
print(f"homogeneous_points:\n{homogeneous_points}")
print(f"intrinsic_3x4:\n{intrinsic_3x4}")
print(f"projected:\n{projected}")
# 4. 归一化坐标(除以深度)
# 提取深度值(Z坐标)
z_coords = projected[2, :]
# 避免除以零
z_coords[z_coords == 0] = 1e-10
# 计算2D坐标
u = projected[0, :] / z_coords
v = projected[1, :] / z_coords
# 组合成2D点数组 (8x2)
points_2d = np.column_stack((u, v))
# 检查可见性(至少2个点在相机前方)
visible = np.sum(corners[2, :] > 0.1) >= 2
return points_2d, visible
def draw_3d_box(image, points, color, label):
"""
在图像上绘制3D边界框
:param image: 输入图像
:param points: 8个角点的2D坐标 (8x2)
:param color: 框的颜色 (B, G, R)
:param label: 类别标签
"""
# 定义框的线连接顺序 (12条边)
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) # 连接底部和顶部
]
# 绘制所有边
for start, end in lines:
pt1 = tuple(points[start].astype(int))
pt2 = tuple(points[end].astype(int))
# 检查点是否在图像边界内
if (0 <= pt1[0] < image.shape[1] and 0 <= pt1[1] < image.shape[0] and
0 <= pt2[0] < image.shape[1] and 0 <= pt2[1] < image.shape[0]):
cv2.line(image, pt1, pt2, color, 2)
# 绘制类别标签
if len(points) > 0:
# 找到最底部的点作为标签位置
min_y_idx = np.argmax(points[:, 1])
label_pos = tuple(points[min_y_idx].astype(int))
cv2.putText(image, label.split('.')[-1], (label_pos[0], label_pos[1] + 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
if __name__ == '__main__':
main()
EOF
3D坐标如何通过相机内参矩阵变成2D图像点
想象你站在一个黑暗的房间里,手里拿着一个手电筒(代表相机)。你面前有一个漂浮在空中的透明立方体(代表3D物体)。当手电筒的光线照射到这个立方体上时,立方体背后的墙上会出现一个影子(代表2D图像)。内参矩阵就是这个"光线投影"的数学描述。
详细步骤分解:
1. 3D空间中的点 (现实世界)
假设我们有一个3D点,比如立方体的一个角点,它在相机坐标系中的位置是 (X, Y, Z):
- X:左右方向(左负右正)
- Y:上下方向(下负上正)
- Z:前后方向(相机前方为正)
2. 透视投影原理 (光线投射)
想象从相机镜头中心发出一条光线,穿过这个3D点:
相机镜头 → 3D点 → 成像平面
这条光线最终会打在相机内部的成像传感器(CCD/CMOS)上,形成图像中的一个点。
3. 内参矩阵的作用 (投影公式)
内参矩阵是描述这个"光线投射"过程的数学公式:
[ u ] [ f_x 0 c_x ] [ X/Z ] [ v ] = [ 0 f_y c_y ] × [ Y/Z ] [ 1 ] [ 0 0 1 ] [ 1 ]
这个公式可以简化为:
u = f_x * (X/Z) + c_x v = f_y * (Y/Z) + c_y
4. 公式分解说明
(1) 透视除法 (X/Z 和 Y/Z)
- 为什么除以 Z?因为物体越远(Z越大),在图像上看起来越小
- 例如:距离相机2米的物体,在图像上的大小是距离1米时的一半
- 这模拟了人眼的透视效果:近大远小
(2) 焦距参数 (f_x 和 f_y)
- f_x 和 f_y 代表相机的焦距
- 焦距越大,视野越窄,物体在图像上越大(相当于望远镜效果)
- 焦距越小,视野越宽,物体在图像上越小(相当于广角效果)
(3) 主点偏移 (c_x 和 c_y)
- c_x 和 c_y 代表图像中心点的位置
- 因为图像坐标系的原点通常在左上角,而光学中心在图像中心
- 这个偏移量校正了坐标系差异
5. 实际例子说明
假设:
- 一个3D点位于 (2米, 1米, 4米)
- 相机参数:f_x = 800, f_y = 800, c_x = 640, c_y = 360
计算过程:
- 透视除法:
- X/Z = 2/4 = 0.5
- Y/Z = 1/4 = 0.25
- 应用焦距:
- f_x * (X/Z) = 800 * 0.5 = 400
- f_y * (Y/Z) = 800 * 0.25 = 200
- 主点偏移:
- u = 400 + 640 = 1040
- v = 200 + 360 = 560
所以这个3D点 (2,1,4) 在1280×720图像上的位置是 (1040, 560)
6. 为什么需要齐次坐标?
在代码中我们看到:
homogeneous_points = np.vstack((corners, np.ones((1, corners.shape[1]))))
- 齐次坐标是在普通坐标基础上增加一个维度(通常是1)
- 3D点 (X,Y,Z) 变成 (X,Y,Z,1)
- 这样做是为了能用单个矩阵乘法完成所有变换:
- 平移(通过矩阵的最后一列)
- 旋转(通过矩阵的前3×3部分)
- 投影(通过整个矩阵)
7. 为什么需要深度信息?
- 深度Z值决定了物体在图像中的大小
- 多个3D点可能投影到同一个2D位置(物体遮挡)
- 深度信息让我们能处理遮挡关系
8. 内参矩阵的物理意义
内参矩阵实际上包含了相机的"指纹"信息:
[ f_x 0 c_x ] [ 0 f_y c_y ] [ 0 0 1 ]
- 对角线上的f_x, f_y, 1:缩放因子
- 右上角的c_x, c_y:平移量
- 零元素:表示没有倾斜(现代相机通常没有倾斜)
3D坐标通过内参矩阵变成2D点的过程,本质上是模拟了相机成像的物理过程:
- 确定3D点在相机坐标系中的位置
- 应用透视原理(近大远小)
- 通过焦距缩放
- 通过主点偏移校正坐标系
五、运行与结果
5.1 运行代码
python visualize_nuscenes.py
5.2 输出示例
处理样本索引: 10...
获取相机数据: CAM_FRONT...
加载图像: nuscenes/samples/CAM_FRONT/n015-2018-07-24-11-22-45+0800__CAM_FRONT__1532402932612460.jpg...
获取相机校准信息...
相机内参矩阵:
[[1.26641720e+03 0.00000000e+00 8.16267020e+02]
[0.00000000e+00 1.26641720e+03 4.91507066e+02]
[0.00000000e+00 0.00000000e+00 1.00000000e+00]]
获取车辆位姿信息...
车辆位姿(rotation):
[[-0.27486103 0.96139551 0.01304215]
[-0.96142562 -0.27466963 -0.01474352]
[-0.01059207 -0.01659148 0.99980625]]
车辆位姿(translation):
[ 399.08211592 1144.61525027 0. ]
获取3D标注信息...
可视化3D边界框...
cam_translation:
[1.70079119 0.01594563 1.51095764]
cam_rotation:
[[ 5.68477868e-03 -5.63666773e-03 9.99967955e-01]
[-9.99983517e-01 -8.37115272e-04 5.68014846e-03]
[ 8.05071338e-04 -9.99983763e-01 -5.64133364e-03]]
corners:
[[20.71362317 20.77283946 20.7443739 20.68515761 21.37947368 21.43868997
21.41022441 21.35100812]
[ 0.17626201 0.16719472 1.80872908 1.81779638 0.18884715 0.17977985
1.82131422 1.83038151]
[18.63646596 19.25456968 19.28137732 18.6632736 18.57286 19.19096372
19.21777136 18.59966764]]
homogeneous_points:
[[20.71362317 20.77283946 20.7443739 20.68515761 21.37947368 21.43868997
21.41022441 21.35100812]
[ 0.17626201 0.16719472 1.80872908 1.81779638 0.18884715 0.17977985
1.82131422 1.83038151]
[18.63646596 19.25456968 19.28137732 18.6632736 18.57286 19.19096372
19.21777136 18.59966764]
[ 1. 1. 1. 1. 1. 1.
1. 1. ]]
intrinsic_3x4:
[[1.26641720e+03 0.00000000e+00 8.16267020e+02 0.00000000e+00]
[0.00000000e+00 1.26641720e+03 4.91507066e+02 0.00000000e+00]
[0.00000000e+00 0.00000000e+00 1.00000000e+00 0.00000000e+00]]
projected:
[[4.14444213e+04 4.20239515e+04 4.20097844e+04 4.14302542e+04
4.22357463e+04 4.28152765e+04 4.28011095e+04 4.22215793e+04]
[9.38317595e+03 9.67549532e+03 1.17675388e+04 1.14752194e+04
9.36785120e+03 9.66017057e+03 1.17522141e+04 1.14598947e+04]
[1.86364660e+01 1.92545697e+01 1.92813773e+01 1.86632736e+01
1.85728600e+01 1.91909637e+01 1.92177714e+01 1.85996676e+01]]
检测到 127 个标注:
adult: 26
car: 11
bicycle: 1
construction_worker: 4
truck: 4
motorcycle: 2
barrier: 27
trafficcone: 12
construction: 2
rigid: 1
保存结果到: nuscenes_3d_boxes.jpg
处理完成! 总耗时: 0.53秒