Python实现NuScenes数据集可视化:从3D边界框到2D图像的投影原理与实践

发布于:2025-07-02 ⋅ 阅读:(18) ⋅ 点赞:(0)

一、 背景介绍

NuScenes数据集是自动驾驶领域的重要数据集之一,包含多个场景的多传感器数据(摄像头、激光雷达等),以及详细的3D物体标注(车辆、行人、障碍物等)。数据可视化是理解和分析自动驾驶数据集的关键第一步。

为什么需要可视化?

  • 理解数据分布:直观查看不同场景中的物体分布
  • 验证标注质量:检查3D标注框是否准确贴合物体
  • 算法调试:验证感知算法的输出结果
  • 研究起点:为后续的目标检测、语义分割等任务提供基础

技术挑战

将3D空间中的物体位置投影到2D图像上涉及复杂的坐标变换,需要理解:

  1. 世界坐标系 → 车辆坐标系
  2. 车辆坐标系 → 相机坐标系
  3. 相机坐标系 → 图像坐标系

本文将详细解释这些变换的原理,并提供完整的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 整体可视化流程

加载数据集
选择样本和相机
读取图像
获取相机内参
获取车辆位姿
获取3D标注
3D到2D投影计算
绘制3D边界框
保存结果

3.2 核心原理:3D到2D投影

坐标系转换流程
车辆移动
相机安装偏移
物体世界位置
车辆位置
物体相对车辆位置
相机位置
物体相对相机位置

实际场景示例
假设:

  • 车辆位置:(10, 5, 0) 世界坐标
  • 相机安装位置:(1.5, 0, 1.7) 车辆坐标
  • 物体位置:(12, 6, 0) 世界坐标

转换过程:

  1. 世界→车辆:
(12,6,0) - (10,5,0) = (2,1,0)  // 物体在车辆前方2米,右侧1米
  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:透视除法,实现"近大远小"效果
投影过程图解
3D点 X,Y,Z
透视除法 X/Z, Y/Z
焦距缩放 f_x*X/Z, f_y*Y/Z
主点偏移 u=f_x*X/Z+c_x, v=f_y*Y/Z+c_y

四、 代码实现详解

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

计算过程:

  1. 透视除法:
  • X/Z = 2/4 = 0.5
  • Y/Z = 1/4 = 0.25
  1. 应用焦距:
  • f_x * (X/Z) = 800 * 0.5 = 400
  • f_y * (Y/Z) = 800 * 0.25 = 200
  1. 主点偏移:
  • 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点的过程,本质上是模拟了相机成像的物理过程:

  1. 确定3D点在相机坐标系中的位置
  2. 应用透视原理(近大远小)
  3. 通过焦距缩放
  4. 通过主点偏移校正坐标系

五、运行与结果

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

请添加图片描述


网站公告

今日签到

点亮在社区的每一天
去签到