在自动驾驶、点云可视化、目标检测等场景中,经常需要把算法输出的 3D 检测框叠加到点云上。
这类检测框并不是直接从文件里读取 8 个顶点,然后交给 Three.js 渲染。以 KITTI 数据为例,标注文件提供的是:
程序需要根据这些数据自己计算出长方体的 8 个角点,再把 8 个点连接成 12 条边,最后使用 Three.js 的 LineSegments 绘制出来。
Three.js 加载 3D 检测框,真正的核心并不是某一个 Three.js API,而是先把检测数据转换成几何数据。
整个过程可以压缩成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 检测数据 | v 长宽高 + 位置 + 旋转 | v 8 个角点 | v 坐标系转换 | v 12 条边 | v BufferGeometry | v LineSegments
其中最关键的三个知识点是:
根据长宽高计算 3D Box 的 8 个角点。
理解 Camera、Velodyne 和 Three.js 之间的坐标系转换。
使用 BufferGeometry + LineSegments 绘制检测框。
理解这三个步骤以后,不只是 KITTI,后续其他能够提供尺寸、位置和旋转信息的 3D 检测结果,也可以按照同样的思路转换成 Three.js 中的 3D 可视化结果。
本文完整梳理 3D 检测框从标注文件到 Three.js 渲染的过程。
1. 整体流程 整个 3D 检测框的加载流程可以概括为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 label_2.txt | | 读取 h、w、l、x、y、z、ry v 计算相机坐标系下的 8 个角点 | | 根据 ry 旋转 | 根据 x、y、z 平移 v Camera 坐标系中的 3D Box | | calib.txt | rectCamToVelo v Velodyne 坐标系 | | (x, y, z) -> (x, z, -y) v Three.js 坐标系 | v 8 个角点连接成 12 条边 | v BufferGeometry | v LineSegments | v 3D 检测框
真正需要关注的核心就是三件事:
根据长宽高生成 8 个角点。
把检测框转换到和点云一致的坐标系。
使用 LineSegments 把 12 条边绘制出来。
2. 3D 检测框的数据从哪里来 KITTI 的检测框数据来自 label_2 标注文件。
例如:
1 Car 0.00 0 -1.55 0.00 0.00 0.00 0.00 1.52 1.72 4.02 7.35 1.64 34.80 -1.58
在当前项目中,和 3D 检测框真正相关的是下面这些字段:
1 2 3 4 5 6 7 8 9 h = 1.52 w = 1.72 l = 4.02 x = 7.35 y = 1.64 z = 34.80 ry = -1.58
分别表示:
字段
含义
h
检测框高度
w
检测框宽度
l
检测框长度
x y z
检测框底部中心位置
ry
绕相机 Y 轴的旋转角
解析代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 export type KittiLabel3D = { type : string ; truncated : number ; occluded : number ; alpha : number ; h : number ; w : number ; l : number ; x : number ; y : number ; z : number ; ry : number ; }; export function parseKittiLabelText (text : string ): KittiLabel3D [] { const out : KittiLabel3D [] = []; for (const line of text.split (/\n/ )) { const t = line.trim (); if (!t) continue ; const p = t.split (/\s+/ ); if (p.length < 15 ) continue ; const type = p[0 ]; const truncated = Number (p[1 ]); const occluded = Number (p[2 ]); const alpha = Number (p[3 ]); const h = Number (p[8 ]); const w = Number (p[9 ]); const l = Number (p[10 ]); const x = Number (p[11 ]); const y = Number (p[12 ]); const z = Number (p[13 ]); const ry = Number (p[14 ]); out.push ({ type , truncated, occluded, alpha, h, w, l, x, y, z, ry }); } return out; }
需要注意:标注文件并没有直接保存 8 个角点。
它只告诉我们:
1 2 3 4 5 这个盒子有多大 + 这个盒子在哪里 + 这个盒子朝哪个方向
8 个角点是程序根据这些参数计算出来的。
3. 为什么长宽高可以计算出 8 个角点 一个 3D 检测框本质上就是一个长方体。
长方体有:
1 2 3 4 5 底面 4 个角 + 顶面 4 个角 = 8 个角
假设先不考虑车辆在世界中的位置,也不考虑旋转。
长度为 l,那么 X 方向的两个边界就是:
宽度为 w,那么 Z 方向的两个边界就是:
KITTI 这里使用的是底部中心位置,所以 Y 方向使用:
因此三个方向分别只有两个可能值:
1 2 3 X: +l/2 或 -l/2 Y: 0 或 -h Z: +w/2 或 -w/2
组合起来就是:
这就是 8 个角点的来源。
代码中使用三个数组分别描述 8 个点的 X、Y、Z:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 const xl = [ l / 2 , l / 2 , -l / 2 , -l / 2 , l / 2 , l / 2 , -l / 2 , -l / 2 , ]; const yl = [ 0 , 0 , 0 , 0 , -h, -h, -h, -h, ]; const zl = [ w / 2 , -w / 2 , -w / 2 , w / 2 , w / 2 , -w / 2 , -w / 2 , w / 2 , ];
可以把这 8 个点理解成:
1 2 3 4 5 6 7 8 9 7---------4 /| /| / | / | 6---------5 | | | | | | 3------|--0 | / | / |/ |/ 2---------1
到这里,我们已经得到了一个没有旋转、位于原点附近的 3D Box。
4. 根据 ry 旋转检测框 真实车辆不可能全部朝着同一个方向。
KITTI 中的 ry 表示目标绕相机 Y 轴的旋转角,因此需要构造旋转矩阵:
1 2 3 4 5 6 7 8 const c = Math .cos (ry);const s = Math .sin (ry);const rot = new THREE .Matrix3 ().set ( c, 0 , s, 0 , 1 , 0 , -s, 0 , c );
然后让刚才计算出的每个局部角点乘上这个旋转矩阵:
1 2 3 4 5 const v = new THREE .Vector3 ( xl[i], yl[i], zl[i] ).applyMatrix3 (rot);
执行完这一步以后,整个检测框就转到了车辆真实的朝向。
1 2 3 4 5 原始局部盒子 | | ry v 旋转后的盒子
5. 根据 x、y、z 把检测框移动到真实位置 现在检测框虽然已经旋转了,但仍然位于原点附近。
KITTI 的 label 中还提供了 x、y、z,因此构造目标位置:
1 const center = new THREE .Vector3 (x, y, z);
然后给每个角点加上这个位置:
1 2 3 4 5 6 7 const v = new THREE .Vector3 ( xl[i], yl[i], zl[i] ) .applyMatrix3 (rot) .add (center);
这里可以拆成两步理解:
1 2 3 4 5 6 7 8 9 局部角点 | | applyMatrix3(rot) v 根据 ry 旋转 | | add(center) v 平移到目标真实位置
完整的 8 个角点生成函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 function cornersCameraRect (label : KittiLabel3D ): THREE .Vector3 [] { const { h, w, l, x, y, z, ry } = label; const xl = [ l / 2 , l / 2 , -l / 2 , -l / 2 , l / 2 , l / 2 , -l / 2 , -l / 2 , ]; const yl = [ 0 , 0 , 0 , 0 , -h, -h, -h, -h, ]; const zl = [ w / 2 , -w / 2 , -w / 2 , w / 2 , w / 2 , -w / 2 , -w / 2 , w / 2 , ]; const c = Math .cos (ry); const s = Math .sin (ry); const rot = new THREE .Matrix3 ().set ( c, 0 , s, 0 , 1 , 0 , -s, 0 , c ); const center = new THREE .Vector3 (x, y, z); const out : THREE .Vector3 [] = []; for (let i = 0 ; i < 8 ; i++) { const v = new THREE .Vector3 (xl[i], yl[i], zl[i]) .applyMatrix3 (rot) .add (center); out.push (v); } return out; }
6. 为什么还需要 calib.txt 这里有一个非常关键的问题。
点云来自 Velodyne LiDAR,而 KITTI 的 label_2 3D 标注位置位于相机坐标系中。
也就是说:
1 2 点云 = Velodyne 坐标系 检测框 = Camera 坐标系
如果直接把两份数据都交给 Three.js 绘制,检测框和点云不会正确重合。
所以必须通过标定文件 calib.txt 进行坐标转换。
项目中主要使用:
首先计算:
1 const veloToRectCam = new THREE .Matrix4 ().multiplyMatrices (r0, tr);
这个矩阵表示:
1 Velodyne -> Rectified Camera
但是现在我们的检测框需要从 Camera 转回 Velodyne,所以对矩阵求逆:
1 const rectCamToVelo = veloToRectCam.clone ().invert ();
得到:
1 Rectified Camera -> Velodyne
7. 把 8 个角点转换到 Velodyne 坐标系 前面 cornersCameraRect() 算出来的 8 个角点还是 Camera 坐标。
因此需要:
1 2 3 4 5 6 7 8 9 10 export function labelCornersInVelo ( label : KittiLabel3D , calib : KittiCalib ): THREE .Vector3 [] { const camCorners = cornersCameraRect (label); return camCorners.map ((p ) => p.applyMatrix4 (calib.rectCamToVelo ) ); }
于是数据从:
转换成:
到这里以后,检测框和 .bin 点云已经位于同一个 LiDAR 坐标系中了。
8. Velodyne 坐标转换成 Three.js 坐标 项目中的 Velodyne 坐标约定为:
为了让 Three.js 中的 Y 轴表示“向上”,项目又进行了一次坐标映射:
1 2 3 4 5 6 7 export function veloPointToThree ( x : number , y : number , z : number ): [number , number , number ] { return [x, z, -y]; }
即:
1 2 3 4 5 Velodyne Three.js x -> x z -> y -y -> z
对于检测框中的 Vector3:
1 2 3 export function remapVectorToThree (v : THREE.Vector3 ): THREE .Vector3 { return new THREE .Vector3 (v.x , v.z , -v.y ); }
因此一条完整的数据转换链是:
1 2 3 4 5 6 7 8 9 10 11 12 KITTI label | v Camera 坐标 | | calib.rectCamToVelo v Velodyne 坐标 | | (x, y, z) -> (x, z, -y) v Three.js 坐标
9. 8 个角点怎么变成 12 条边 拿到 Three.js 坐标系中的 8 个角点以后,还不能直接看到盒子,因为 Three.js 还不知道哪些点应该连接。
长方体一共有 12 条边。
项目中使用固定索引描述这些连接关系:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export const BOX_EDGE_INDEX_PAIRS : [number , number ][] = [ [0 , 1 ], [1 , 2 ], [2 , 3 ], [3 , 0 ], [4 , 5 ], [5 , 6 ], [6 , 7 ], [7 , 4 ], [0 , 4 ], [1 , 5 ], [2 , 6 ], [3 , 7 ], ];
前三组分别表示:
1 2 3 4 5 6 7 底面 4 条边 + 顶面 4 条边 + 上下连接的 4 条边 = 12 条边
然后把每条边的两个端点写进一个 Float32Array:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function edgesFromCorners (corners : THREE.Vector3 [] ): Float32Array { const arr = new Float32Array ( BOX_EDGE_INDEX_PAIRS .length * 2 * 3 ); let k = 0 ; for (const [a, b] of BOX_EDGE_INDEX_PAIRS ) { const ca = corners[a]; const cb = corners[b]; arr[k++] = ca.x ; arr[k++] = ca.y ; arr[k++] = ca.z ; arr[k++] = cb.x ; arr[k++] = cb.y ; arr[k++] = cb.z ; } return arr; }
这里有:
1 2 3 4 5 6 7 12 条边 x 每条边 2 个点 x 每个点 3 个坐标 = 72 个数字
10. 使用 BufferGeometry 保存线段顶点 得到线段位置数组以后,就可以创建 Three.js 的 BufferGeometry:
1 2 3 4 5 6 7 8 const positions = edgesFromCorners (corners);const geometry = new THREE .BufferGeometry ();geometry.setAttribute ( "position" , new THREE .BufferAttribute (positions, 3 ) );
这里的 3 表示每 3 个数字构成一个顶点:
数据最终进入:
1 2 3 4 5 6 7 8 9 10 Float32Array | v BufferAttribute | v BufferGeometry.attributes.position | v GPU 顶点数据
11. 使用 LineSegments 绘制检测框 因为我们要画的是线框,不需要生成实体 Mesh。
最适合的对象就是:
在 React Three Fiber 中可以写成:
1 2 3 4 5 6 7 8 <lineSegments geometry={geometry}> <lineBasicMaterial color ="#22d3ee" depthTest transparent opacity ={0.95} /> </lineSegments>
LineSegments 会按照:
1 2 3 4 第 0、1 个顶点 -> 一条线 第 2、3 个顶点 -> 一条线 第 4、5 个顶点 -> 一条线 ...
进行绘制。
这正好和前面生成的 12 组边对应。
12. 项目里的 LabelBoxes 逻辑 把前面的流程组合起来,核心逻辑就是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 export function LabelBoxes ({ labels, calib }: Props ) { const segments = useMemo (() => { return labels.map ((label ) => { const corners = labelCornersInVelo (label, calib).map ((c ) => remapVectorToThree (c) ); const positions = edgesFromCorners (corners); const geom = new THREE .BufferGeometry (); geom.setAttribute ( "position" , new THREE .BufferAttribute (positions, 3 ) ); return { geom, color : defaultColor (label.type ), }; }); }, [labels, calib]); return ( <group > {segments.map(({ geom, color }, index) => ( <lineSegments key ={index} geometry ={geom} frustumCulled ={false} > <lineBasicMaterial color ={color} depthTest transparent opacity ={0.95} /> </lineSegments > ))} </group > ); }
虽然这里使用的是 React Three Fiber 写法,但底层本质仍然是 Three.js:
1 2 3 4 5 6 7 BufferGeometry + BufferAttribute + LineSegments + LineBasicMaterial
13. 整个过程再看一遍 现在重新看完整流程就比较清楚了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 1. 读取 label_2.txt | v 2. 得到 h w l、x y z、ry | v 3. 根据 +/-l/2、0/-h、+/-w/2 生成 8 个局部角点 | v 4. 根据 ry 旋转 | v 5. 根据 x y z 平移 | v 6. 得到 Camera 坐标中的 8 个角点 | | rectCamToVelo v 7. 转成 Velodyne 坐标 | | (x,y,z) -> (x,z,-y) v 8. 转成 Three.js 坐标 | v 9. 8 个角点组成 12 条边 | v 10. BufferGeometry | v 11. LineSegments | v 12. 显示 3D 检测框
14. 为什么不用 BoxGeometry 看到这里可能会有一个疑问:既然检测框就是一个长方体,为什么不直接使用:
在这套实现中,直接计算最终 8 个角点有几个好处:
可以直接使用检测算法输出的尺寸、位置和旋转数据。
Camera、Velodyne、Three.js 之间的坐标转换更直观。
检测框只需要线框,不需要额外生成三角面。
后续方便做不同类别颜色、拾取和高亮。
所以对于自动驾驶中的 3D Bounding Box,使用:
1 2 3 4 5 8 个角点 + 12 条边 + LineSegments
是一种很直接的实现方式。