简介这份资源提供nuScenes数据集3D转2D的Python实现源码面向从事自动驾驶感知、计算机视觉与3D目标检测的开发者及学习者帮助解决将激光雷达等3D标注信息投影到多视角相机图像上的实际问题。压缩包共2个文件均为py脚本整体约4KB分别承担2D边界框绘制与整体流程串联的职责便于直接阅读与二次修改。核心逻辑涵盖世界坐标系到相机坐标系的转换、基于相机内参的透视投影、畸变校正以及由3D边界盒顶点推算2D框中心、尺寸与旋转角度等关键环节。目前已有2646人学习下载说明该实现具备一定参考价值。通过运行与调试这些脚本读者可掌握3D到2D转换的完整链路并将其迁移到自己的检测可视化或数据分析项目中是入门nuScenes多传感器融合与投影计算的实用素材。1. 从 nuScenes 的 3D 框到 2D 框为什么你的标注投影总对不上做自动驾驶感知的同行大概率都碰过这个场景手头有一份 nuScenes 数据集想拿它训练 2D 检测器或者想把 3D 标注框投影到图像上做可视化验证结果发现投影出来的框要么整体偏移要么大小完全不对要么干脆跑到图像外面去了。这不是玄学绝大多数情况下是坐标系链路没理清楚。nuScenes 的标注是全局坐标系下的 3D 框而图像是某个特定相机在某个特定时刻拍的中间要经过全局→自车→相机→图像四级变换任何一级搞错投影结果就是灾难。这篇笔记就是围绕「nuScenes 数据集 3D 转 2D python 源码」这个具体需求把坐标系链路、代码实现、参数配置和踩坑经验一次讲透。适合正在做多模态融合、2D 检测预标注、或者需要做投影可视化的工程师新手可以照着代码跑通熟手可以对照检查自己的变换链路是否有遗漏。2. 坐标系链路拆解从全局 3D 框到像素坐标到底经过了几步2.1 nuScenes 的坐标系定义与标注结构nuScenes 的标注体系里3D 框存储在sample_annotation表中每个框包含translation全局坐标系下的中心点 xyz、size宽高长 whl、rotation四元数全局坐标系下的朝向。注意这里的 translation 和 rotation 都是全局坐标系global frame下的值不是自车坐标系也不是相机坐标系。很多新手第一次做投影时直接拿这个全局坐标去乘相机内参结果当然是完全错的。nuScenes 的坐标系层级是这样的全局坐标系global→ 自车坐标系ego→ 相机坐标系camera→ 图像像素坐标系pixel。每一级之间都有明确的变换矩阵这些矩阵都可以从 nuScenes 的 devkit 中直接获取不需要自己手算。自车坐标系的原点在自车后轴中心x 轴指向前方y 轴指向左方z 轴指向上方。相机坐标系的原点在相机光心x 轴指向右方y 轴指向下方z 轴指向前方。这两个坐标系的定义差异是后面投影出错的高频原因之一。2.2 四级变换矩阵的获取与含义从全局到像素需要串联四个变换变换步骤矩阵来源含义global → egosample_data.ego_pose全局到自车的旋转平移ego → camerasample_data.calibrated_sensor自车到相机的外参camera → 图像calibrated_sensor.camera_intrinsic相机内参 3x3畸变校正sample_data对应的畸变参数可选nuScenes 图像已去畸变在 nuScenes devkit 中ego_pose包含translation和rotation四元数calibrated_sensor包含translation、rotation和camera_intrinsic。这些数据都可以通过nusc.get(sample_data, token)拿到。关键点ego_pose 是全局到自车的变换如果你要做 global → ego用的是 ego_pose 的逆变换。同理calibrated_sensor 的 translation 和 rotation 描述的是相机在自车坐标系下的位姿ego → camera 也需要取逆。这个「取逆」操作是投影错误的第一大来源。2.3 四元数转旋转矩阵的实现细节nuScenes 的旋转用四元数表示格式是[w, x, y, z]。转旋转矩阵的公式是标准的但要注意归一化。我一般会先做归一化再转矩阵避免数值误差累积。import numpy as np def quaternion_to_rotation_matrix(quat): 将 nuScenes 格式的四元数 [w, x, y, z] 转为 3x3 旋转矩阵 参数: quat -- 长度为4的列表或数组, 顺序为 [w, x, y, z] 返回: 3x3 numpy 数组 q np.array(quat, dtypenp.float64) # 归一化防止数值误差导致矩阵不正交 q q / np.linalg.norm(q) w, x, y, z q rotation_matrix np.array([ [1 - 2*(y**2 z**2), 2*(x*y - w*z), 2*(x*z w*y)], [2*(x*y w*z), 1 - 2*(x**2 z**2), 2*(y*z - w*x)], [2*(x*z - w*y), 2*(y*z w*x), 1 - 2*(x**2 y**2)] ]) return rotation_matrix这段代码的核心逻辑是标准四元数转旋转矩阵公式。参数说明输入 quat 必须是[w, x, y, z]顺序nuScenes 的 rotation 字段就是这个顺序。归一化那一步看起来多余但在批量处理几千帧数据时不做归一化偶尔会出现投影框轻微旋转的情况属于血泪经验。2.4 构建完整的变换矩阵并投影拿到各级变换后构建 4x4 变换矩阵把 3D 框的 8 个角点依次变换到图像平面。def get_transform_matrix(translation, rotation): 构建 4x4 变换矩阵 mat np.eye(4) mat[:3, :3] quaternion_to_rotation_matrix(rotation) mat[:3, 3] translation return mat def project_3d_box_to_2d(box_center_global, box_size, box_rotation_global, ego_pose, cam_calibrated_sensor): 将单个 3D 框投影到图像返回 2D 框 [x_min, y_min, x_max, y_max] 参数: box_center_global: 全局坐标系下框中心 [x, y, z] box_size: [width, length, height] box_rotation_global: 全局坐标系下四元数 [w, x, y, z] ego_pose: dict, 含 translation 和 rotation cam_calibrated_sensor: dict, 含 translation, rotation, camera_intrinsic # 全局 - 自车 ego_transform get_transform_matrix( ego_pose[translation], ego_pose[rotation]) global_to_ego np.linalg.inv(ego_transform) # 自车 - 相机 cam_transform get_transform_matrix( cam_calibrated_sensor[translation], cam_calibrated_sensor[rotation]) ego_to_cam np.linalg.inv(cam_transform) # 组合: global - camera global_to_cam ego_to_cam global_to_ego # 生成 3D 框的 8 个角点在全局坐标系下 w, l, h box_size 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] ]) # 旋转到全局朝向 rot_global quaternion_to_rotation_matrix(box_rotation_global) corners (rot_global corners.T).T np.array(box_center_global) # 变换到相机坐标系 corners_homo np.hstack([corners, np.ones((8, 1))]) corners_cam (global_to_cam corners_homo.T).T[:, :3] # 过滤掉相机后方的点 valid_mask corners_cam[:, 2] 0.1 if valid_mask.sum() 2: return None # 投影到图像 intrinsic np.array(cam_calibrated_sensor[camera_intrinsic]) corners_img (intrinsic corners_cam.T).T corners_img corners_img[:, :2] / corners_cam[:, 2:3] x_min, y_min corners_img.min(axis0) x_max, y_max corners_img.max(axis0) return [x_min, y_min, x_max, y_max]逻辑说明先构建全局到自车、自车到相机的变换矩阵注意都是取逆。然后把 3D 框的 8 个角点从框局部坐标系旋转到全局朝向再平移到全局位置。接着用组合矩阵变换到相机坐标系过滤掉 z 小于等于 0.1 的点相机后方的点投影没有意义。最后用内参投影并取外接矩形。参数说明box_size的顺序是[width, length, height]对应 nuScenes 的size字段。角点生成时我把 length 放在 x 方向、width 放在 y 方向这和 nuScenes 的框定义一致。valid_mask的阈值 0.1 是个经验值太小会引入数值不稳定太大会丢掉近处物体。3. 批量处理与可视化把投影结果画到图像上验证3.1 遍历 sample 并关联标注与相机数据单帧投影跑通后下一步是批量处理。nuScenes 的数据组织是 sample → sample_data → calibrated_sensor 的链路需要正确关联。from nuscenes.nuscenes import NuScenes import cv2 def process_sample(nusc, sample_token, cam_channelCAM_FRONT): 处理单个 sample返回该相机视角下所有 3D 框的 2D 投影 参数: nusc: NuScenes 实例 sample_token: sample 的 token cam_channel: 相机通道名如 CAM_FRONT, CAM_BACK_LEFT 等 sample nusc.get(sample, sample_token) cam_token sample[data][cam_channel] cam_data nusc.get(sample_data, cam_token) cam_calibrated nusc.get(calibrated_sensor, cam_data[calibrated_sensor_token]) ego_pose nusc.get(ego_pose, cam_data[ego_pose_token]) results [] for ann_token in sample[anns]: ann nusc.get(sample_annotation, ann_token) box_2d project_3d_box_to_2d( ann[translation], ann[size], ann[rotation], ego_pose, cam_calibrated) if box_2d is not None: results.append({ category: ann[category_name], box_2d: box_2d, token: ann_token }) return results, cam_data逻辑说明通过 sample 的data字段拿到指定相机的 sample_data token再拿到 calibrated_sensor 和 ego_pose。遍历该 sample 的所有标注逐个投影。返回结果包含类别、2D 框和标注 token方便后续可视化或导出。参数说明cam_channel支持 nuScenes 的 6 个相机通道。注意每个相机的 ego_pose 可能不同因为曝光时刻有微小差异所以必须用对应相机的 ego_pose不能混用。3.2 可视化验证与常见偏差排查投影完必须可视化验证否则你不知道对不对。def visualize_projection(nusc, sample_token, cam_channelCAM_FRONT, output_pathprojection.jpg): 将投影结果画到图像上并保存 results, cam_data process_sample(nusc, sample_token, cam_channel) img_path nusc.get_sample_data_path(cam_data[token]) img cv2.imread(img_path) for r in results: x_min, y_min, x_max, y_max [int(v) for v in r[box_2d]] # 裁剪到图像范围内 h, w img.shape[:2] x_min, y_min max(0, x_min), max(0, y_min) x_max, y_max min(w, x_max), min(h, y_max) cv2.rectangle(img, (x_min, y_min), (x_max, y_max), (0, 255, 0), 2) cv2.putText(img, r[category].split(.)[0], (x_min, y_min - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imwrite(output_path, img) return img逻辑说明读取原始图像把每个投影框画上去类别名取category_name的第一段去掉后缀。裁剪到图像范围是必要的因为有些框会部分超出图像边界。参数说明output_path是保存路径。线宽和字体大小可以根据图像分辨率调整nuScenes 图像是 1600x900线宽 2 比较合适。如果投影结果整体偏移优先检查 ego_pose 是否用了对应相机的如果框大小不对检查box_size的顺序是不是[w, l, h]如果框上下颠倒检查相机坐标系的 y 轴方向。3.3 导出为 COCO 格式供 2D 检测训练投影结果可以直接导出为 COCO 格式用于 2D 检测预标注或蒸馏。import json def export_to_coco(nusc, sample_tokens, cam_channelCAM_FRONT, output_jsoncoco_export.json): 将多个 sample 的投影结果导出为 COCO 格式 coco { images: [], annotations: [], categories: [] } cat_set {} ann_id 0 for img_id, sample_token in enumerate(sample_tokens): results, cam_data process_sample(nusc, sample_token, cam_channel) img_path nusc.get_sample_data_path(cam_data[token]) img cv2.imread(img_path) h, w img.shape[:2] coco[images].append({ id: img_id, file_name: img_path.split(/)[-1], width: w, height: h }) for r in results: cat r[category] if cat not in cat_set: cat_set[cat] len(cat_set) 1 x_min, y_min, x_max, y_max r[box_2d] coco[annotations].append({ id: ann_id, image_id: img_id, category_id: cat_set[cat], bbox: [x_min, y_min, x_max - x_min, y_max - y_min], area: (x_max - x_min) * (y_max - y_min), iscrowd: 0 }) ann_id 1 coco[categories] [ {id: v, name: k} for k, v in cat_set.items()] with open(output_json, w) as f: json.dump(coco, f) return coco逻辑说明遍历 sample 列表每个 sample 生成一条 image 记录每个投影框生成一条 annotation 记录。类别 ID 动态分配。COCO 的 bbox 格式是[x, y, width, height]注意和[x_min, y_min, x_max, y_max]的转换。参数说明sample_tokens是 sample token 列表可以从nusc.sample中取。output_json是输出路径。这个导出脚本可以直接对接 mmdetection 等框架。4. 避坑与排查投影不对时先查这五个地方4.1 投影框整体偏移或旋转现象投影出来的框位置大致对但整体偏移几十个像素或者框的朝向明显不对。原因最常见的是 ego_pose 用错了。nuScenes 每个 sample_data 有自己的 ego_pose_token不同相机的 ego_pose 可能不同。如果用了 sample 级别的 ego_pose 或者别的相机的 ego_pose就会出现偏移。解决确认nusc.get(ego_pose, cam_data[ego_pose_token])用的是当前相机 sample_data 的 ego_pose_token不要跨相机混用。4.2 框大小完全不对现象投影框的位置对但大小差很多要么特别大要么特别小。原因box_size的顺序搞错了。nuScenes 的 size 字段是[width, length, height]不是[length, width, height]也不是[h, w, l]。另外角点生成时如果 length 和 width 放反了框会旋转 90 度。解决打印几个标注的 size 值对照 nuScenes 文档确认顺序。角点生成时保持和 size 顺序一致。4.3 框跑到图像外面或消失现象有些框投影后完全在图像外或者返回 None。原因相机后方的点被过滤掉了或者框本身就不在该相机视野内。nuScenes 每个 sample 有 6 个相机不是所有标注都能在每个相机里看到。解决这是正常现象。检查valid_mask.sum() 2的条件是否过于严格可以放宽到 1。另外确认相机通道和标注的可见性是否匹配。4.4 四元数顺序搞反现象投影框的旋转完全错误框的方向和实际物体垂直或反向。原因四元数顺序搞反了。nuScenes 是[w, x, y, z]但有些库如 scipy默认是[x, y, z, w]。如果混用就会出错。解决统一用[w, x, y, z]或者在转换前显式调整顺序。我一般会在函数入口加一行断言检查。4.5 批量处理时内存溢出现象处理几千个 sample 时内存持续增长最后 OOM。原因nuScenes devkit 会缓存数据批量处理时如果不释放内存会累积。解决分批处理每批处理完后手动清理缓存或者用nusc.list_scenes()分场景处理。另外图像读取后及时释放不要全部留在内存里。5. 进阶技巧用向量化加速和投影质量过滤批量投影几千帧时逐框循环会很慢。我一般会把角点生成和矩阵乘法向量化速度能提升 5 到 10 倍。核心思路是把所有框的角点拼成一个大的 numpy 数组用一次矩阵乘法完成变换。def batch_project(boxes, ego_pose, cam_calibrated): 向量化批量投影 参数: boxes: list of dict, 每个含 translation, size, rotation 返回: list of 2D box or None n len(boxes) # 批量生成角点 (n, 8, 3) all_corners np.zeros((n, 8, 3)) for i, box in enumerate(boxes): w, l, h box[size] 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] ]) rot quaternion_to_rotation_matrix(box[rotation]) all_corners[i] (rot corners.T).T np.array(box[translation]) # 组合变换矩阵 ego_mat get_transform_matrix( ego_pose[translation], ego_pose[rotation]) cam_mat get_transform_matrix( cam_calibrated[translation], cam_calibrated[rotation]) global_to_cam np.linalg.inv(cam_mat) np.linalg.inv(ego_mat) # 批量变换 (n*8, 4) - (n*8, 3) corners_flat all_corners.reshape(-1, 3) corners_homo np.hstack([corners_flat, np.ones((corners_flat.shape[0], 1))]) corners_cam (global_to_cam corners_homo.T).T[:, :3] corners_cam corners_cam.reshape(n, 8, 3) # 批量投影 intrinsic np.array(cam_calibrated[camera_intrinsic]) results [] for i in range(n): valid corners_cam[i, :, 2] 0.1 if valid.sum() 2: results.append(None) continue pts corners_cam[i][valid] img_pts (intrinsic pts.T).T img_pts img_pts[:, :2] / pts[:, 2:3] results.append([ img_pts[:, 0].min(), img_pts[:, 1].min(), img_pts[:, 0].max(), img_pts[:, 1].max() ]) return results这段代码把角点生成和变换都批量化了只有最后的投影和边界计算还在循环里。实测在几千个框的量级上比逐框处理快 5 倍以上。参数说明boxes是标注列表每个元素包含translation、size、rotation三个字段直接从sample_annotation里取就行。另一个进阶技巧是投影质量过滤。不是所有投影框都值得保留我一般会加三个过滤条件框面积小于图像面积 0.1% 的丢掉太远太小、框超出图像边界超过 50% 的丢掉不完整、宽高比异常的丢掉投影畸变严重。这三个条件能过滤掉大部分低质量投影导出的 COCO 标注质量会好很多。最后一个习惯每次改完投影代码先拿一帧有代表性的 sample 跑可视化肉眼确认框贴合物体再批量跑。这个习惯帮我省了无数次重新导出的时间。投影这件事坐标系链路对了就对了错了就是全错没有中间状态。希望帮到你。本文还有配套的精品资源点击获取