threejs加载3D检测框

在自动驾驶、点云可视化、目标检测等场景中,经常需要把算法输出的 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 检测框

真正需要关注的核心就是三件事:

  1. 根据长宽高生成 8 个角点。
  2. 把检测框转换到和点云一致的坐标系。
  3. 使用 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 方向的两个边界就是:

1
2
+l / 2
-l / 2

宽度为 w,那么 Z 方向的两个边界就是:

1
2
+w / 2
-w / 2

KITTI 这里使用的是底部中心位置,所以 Y 方向使用:

1
2
底面:0
顶面:-h

因此三个方向分别只有两个可能值:

1
2
3
X: +l/2 或 -l/2
Y: 0 或 -h
Z: +w/2 或 -w/2

组合起来就是:

1
2 x 2 x 2 = 8

这就是 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
2
R0_rect
Tr_velo_to_cam

首先计算:

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)
);
}

于是数据从:

1
Camera 3D Box

转换成:

1
Velodyne 3D Box

到这里以后,检测框和 .bin 点云已经位于同一个 LiDAR 坐标系中了。


8. Velodyne 坐标转换成 Three.js 坐标

项目中的 Velodyne 坐标约定为:

1
2
3
x:向前
y:向左
z:向上

为了让 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
x y z

数据最终进入:

1
2
3
4
5
6
7
8
9
10
Float32Array
|
v
BufferAttribute
|
v
BufferGeometry.attributes.position
|
v
GPU 顶点数据

11. 使用 LineSegments 绘制检测框

因为我们要画的是线框,不需要生成实体 Mesh。

最适合的对象就是:

1
THREE.LineSegments

在 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

看到这里可能会有一个疑问:既然检测框就是一个长方体,为什么不直接使用:

1
new THREE.BoxGeometry()

在这套实现中,直接计算最终 8 个角点有几个好处:

  1. 可以直接使用检测算法输出的尺寸、位置和旋转数据。
  2. Camera、Velodyne、Three.js 之间的坐标转换更直观。
  3. 检测框只需要线框,不需要额外生成三角面。
  4. 后续方便做不同类别颜色、拾取和高亮。

所以对于自动驾驶中的 3D Bounding Box,使用:

1
2
3
4
5
8 个角点
+
12 条边
+
LineSegments

是一种很直接的实现方式。


作者

wangyawei

发布于

2026-03-21

更新于

2026-09-14

许可协议