面向读者: 具备基础编程能力、希望系统掌握「全栈三维标注工具」工程实践的 Formula Student 队员
技术栈: Vue 3 + Vite | Three.js r181 | FastAPI | OpenPCDet 标签格式
┌─────────────────────────────────────────────────────────────────┐
│ 用户浏览器 │
│ ┌──────────┐ ┌────────────────────────┐ ┌─────────────────┐ │
│ │ 文件列表 │ │ 主 3D 画布 (WebGL) │ │ 右侧边栏 │ │
│ │ 标注列表 │ │ PerspectiveCamera │ │ 三视图 (3xWebGL)│ │
│ │ │ │ OrthographicCamera(BEV)│ │ Inspector 表单 │ │
│ └──────────┘ └────────────────────────┘ └─────────────────┘ │
│ Vue 3 Composition API + Element Plus + Three.js │
├─────────────────────────────────────────────────────────────────┤
│ Vite Dev Server (proxy) │
│ /api/* → :8000 /data/* → :8000 │
├─────────────────────────────────────────────────────────────────┤
│ FastAPI Backend (:8000) │
│ GET /api/files → 列出 data/test/lidar/*.pcd │
│ GET /api/labels/{file} → 读取 data/test/label/{stem}.txt │
│ POST /api/labels/{file} → 写入 OpenPCDet 格式标签 │
│ StaticFiles /data/* → 直接 serve data/ 目录 │
└─────────────────────────────────────────────────────────────────┘
│ 文件系统 │
│ data/test/lidar/000001.pcd ← 激光雷达原始点云 │
│ data/test/label/000001.txt ← 标注文件 (OpenPCDet格式) │
└─────────────────────────────────────────────────────────────────┘
从点击一个 .pcd 文件到保存标注,数据经历以下完整链路:
[1] 用户点击文件
│
▼
[2] selectFile(file) ─── 清空旧标注 ─── loadPcd(filename)
│
▼
[3] PCDLoader.load(url) ── HTTP GET /data/test/lidar/000001.pcd
│ ▲ Vite proxy 转发到 FastAPI StaticFiles
▼
[4] 点云 geometry.rotateX(-PI/2) ── LiDAR Z-up 转 Three.js Y-up
│
▼
[5] scene.add(points) ── 计算 BoundingBox ── 初始化相机位姿
│
▼
[6] loadLabels(filename) ── HTTP GET /api/labels/000001.pcd
│ ▲ FastAPI 读取 000001.txt
▼
[7] 对每个 label: 创建 annotation 对象 + createBoxMesh(ann)
│
▼
[8] 用户 Shift+Click ── Raycaster 找到点云交点 ── 创建新 annotation
│
▼
[9] TransformControls 拖拽 ── syncTransformToAnnotation() ── 双向绑定到 Inspector
│
▼
[10] onSave() ── HTTP POST /api/labels/000001.pcd
│ ▲ FastAPI 写入 000001.txt
▼
[11] data/test/label/000001.txt:
1.2340 -0.5670 0.1500 0.2000 0.2000 0.3000 0.7854 Cone
本项目最核心的架构决策是如何协调两套完全不同的编程范式:
| 维度 | Vue 3 (声明式/响应式) | Three.js (命令式/WebGL) |
|---|---|---|
| 数据更新 | ref() 自动追踪依赖 |
手动 mesh.position.set() |
| DOM/渲染 | 虚拟 DOM 自动 diff | requestAnimationFrame 逐帧渲染 |
| 生命周期 | onMounted / onBeforeUnmount |
手动 dispose() / cancelAnimationFrame |
我们的核心设计选择:Vue ref 作为 Single Source of Truth,Three.js mesh 作为「视图层」。
annotations (ref) ──────────── 权威数据 (LiDAR 坐标)
│ ▲
│ │ syncTransformToAnnotation() // Three.js → Vue
│ │ watch(inspectorForm, ...) // Vue → Three.js
▼ │
boxMeshes (Map) ──────────── 渲染视图 (Three.js 坐标)
关键代码展示这个双向绑定:
// Three.js → Vue (拖拽 gizmo 后)
function syncTransformToAnnotation() {
ann.position.x = mesh.position.x // Three.js X → LiDAR X
ann.position.y = mesh.position.z // Three.js Z → LiDAR Y
ann.position.z = mesh.position.y // Three.js Y → LiDAR Z
}
// Vue → Three.js (Inspector 表单修改后)
watch(inspectorForm, (form) => {
mesh.position.set(form.x, form.z, form.y) // LiDAR → Three.js
mesh.rotation.y = form.yaw
})许多教程用 renderer.setViewport() + renderer.setScissor() 实现多视口。但我们必须使用独立 Renderer,核心原因是 WebGL Clipping Planes:
// 主渲染器:显示完整点云,不裁剪
renderer.localClippingEnabled = false
// 三视图渲染器:只显示标注框附近的点云
ctxRenderer.localClippingEnabled = true
currentPoints.material.clippingPlanes = clipPlanes // 6 个裁剪平面localClippingEnabled 是 Renderer 级别 的开关。同一个 Renderer 无法对同一个 Material 在不同视口中施加不同的裁剪行为。这就是为什么参考项目 SUSTechPOINTS 也采用了独立 Renderer 架构。
这是整个项目中最容易出错、影响最广泛的数学问题。
两套坐标系定义:
LiDAR (Z-up, 自动驾驶标准) Three.js (Y-up, 图形学标准)
Z (上) Y (上)
│ │
│ │
│_____ Y (左) │_____ X (右)
/ /
/ /
X (前) Z (前/屏幕外)
转换关系(本项目实际使用的映射):
Three.js X = LiDAR X (前方不变)
Three.js Y = LiDAR Z (LiDAR的「上」变成Three.js的「上」)
Three.js Z = LiDAR Y (LiDAR的「左」变成Three.js的「前」)
为什么用 rotateX(-PI/2) 实现?
这是一个绕 X 轴旋转 -90 度的操作。用旋转矩阵表示:
R_x(-90°) = ┌ 1 0 0 ┐
│ 0 cos(-90) -sin(-90) │
│ 0 sin(-90) cos(-90) │
└ ┘
= ┌ 1 0 0 ┐
│ 0 0 1 │
│ 0 -1 0 │
└ ┘
对一个 LiDAR 点 (x_L, y_L, z_L) 应用这个旋转:
┌ x_T ┐ ┌ 1 0 0 ┐ ┌ x_L ┐ ┌ x_L ┐
│ y_T │ = │ 0 0 1 │ × │ y_L │ = │ z_L │
│ z_T │ │ 0 -1 0 │ │ z_L │ │ -y_L │
└ ┘ └ ┘ └ ┘ └ ┘
即 Three.js (x, y, z) = LiDAR (x, z, -y)。但实际上由于 PCDLoader 加载时 LiDAR Y 轴的正负定义差异,最终等效于:
Three.js X = LiDAR X
Three.js Y = LiDAR Z
Three.js Z = LiDAR Y
代码中的体现(关键映射表):
// 创建 mesh: LiDAR → Three.js
mesh.position.set(ann.position.x, ann.position.z, ann.position.y)
// ^^^^^^^ X=X ^^^^^^^ Y←Z ^^^^^^^ Z←Y
// 同步回 annotation: Three.js → LiDAR
ann.position.x = mesh.position.x // LiDAR X ← Three.js X
ann.position.y = mesh.position.z // LiDAR Y ← Three.js Z
ann.position.z = mesh.position.y // LiDAR Z ← Three.js Y教训: 坐标映射必须在项目最初期就确定,并在代码中用注释严格标注每个转换点。一旦搞混,所有视图、所有交互、所有保存/加载全部会错。
用户点击屏幕的一个像素,如何找到点云中对应的三维点?
Step 1: 屏幕坐标 → NDC(归一化设备坐标)
const rect = container.getBoundingClientRect()
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 // [-1, +1]
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 // [-1, +1],注意Y翻转为什么 Y 轴要翻转?因为屏幕坐标系 Y 轴向下,而 NDC Y 轴向上。
屏幕坐标: NDC:
(0,0)────→ X Y (+1)
│ │
│ │
▼ Y ────O────→ X
│ (+1)
(-1)
Step 2: NDC → 射线 (Ray)
raycaster.setFromCamera(mouse, camera)对于 透视相机,射线从相机位置出发,穿过 NDC 点对应的近平面位置。 对于 正交相机 (BEV),射线垂直于近平面,从 NDC 对应的位置平行射出。
透视投影 (Perspective): 正交投影 (Orthographic):
camera ─────── near plane ───────
/|\ │ ↓ ↓ ↓ ↓ ↓ ↓ ↓ │
/ | \ │ ↓ ↓ ↓ ↓ ↓ ↓ ↓ │
/ | \ ← 射线是锥形的 │ ↓ ↓ ↓ ↓ ↓ ↓ ↓ │
/___|___\ ─────── far plane ────────
near plane 射线是平行的
Step 3: 射线与点云求交
raycaster.intersectObject(currentPoints) 对稀疏点云使用 threshold 近似检测:
raycaster.params.Points.threshold = 0.3 // 单位:米含义:射线不需要精确命中某个点(直径为零的点不可能精确命中),只需射线与某个点的距离小于 0.3m 即视为命中。对于锥桶 (0.2m 宽) 而言,0.3m 的阈值既不会错过锥桶表面的稀疏点,也不会频繁误触。
三视图需要只显示标注框附近的点云。我们用 6 个 THREE.Plane 构造一个 AABB (Axis-Aligned Bounding Box) 裁剪区域。
// THREE.Plane(normal, constant) 的裁剪规则:
// 保留满足 dot(normal, point) + constant >= 0 的点
// 即:法线指向「保留」的一侧
clipPlanes[0].set(new THREE.Vector3(-1, 0, 0), cx + hl) // +X 面:保留 x < cx+hl
clipPlanes[1].set(new THREE.Vector3( 1, 0, 0), -cx + hl) // -X 面:保留 x > cx-hl几何含义如下:
-X 面 +X 面
│ │
被裁剪 ← │ → 保留 ← [box] → 保留 ← │ → 被裁剪
│ center │
cx-hl cx+hl
6 个面共同定义了一个 3D 长方体区域,PAD = 1.5m 是留给周围上下文点云的余量。
正交相机的 near 和 far 不是从原点算的,而是从相机位置沿视线方向的偏移量。
camera.position = box_center + CAM_DIST * view_direction
如果 near = CAM_DIST - halfDepth, far = CAM_DIST + halfDepth:
相机位置 near 平面 box 中心 far 平面
●──────────────────│────────────── ◆ ──────────────│
├── CAM_DIST ──────┤ │
├── halfDepth ──┤── halfDepth ──┤
这实现了「只渲染 box 厚度范围内的点云」的效果,是 SUSTechPOINTS 的核心思路:near/far = box.scale + 0.8。
放置锥桶时自动计算 yaw 角,使框面向传感器原点:
const yaw = Math.atan2(hitPoint.x, hitPoint.z)几何含义:
Three.js Z (LiDAR Y)
↑
│ hitPoint
│ /
│/ yaw = atan2(x, z)
─────────O──────────→ Three.js X (LiDAR X)
传感器原点
atan2(x, z) 返回从 +Z 轴到 (x, z) 方向的夹角,使得锥桶的正面始终朝向传感器。
严重程度: P0 (核心功能不可用)
复现路径:
- 选中一个 box → 三视图正常显示
- 按 Esc 取消选择 → 三视图消失(预期)
- Shift+Click 创建新 box → 三视图重新出现但完全黑屏
根因分析:
<!-- 错误写法 -->
<div v-if="selectedAnnotationId" class="ctx-views-panel">
<div id="ctx-top" class="ctx-view-container" />
...
</div>v-if 指令在条件为 false 时会彻底销毁 DOM 子树。这意味着:
选中 box: Vue 创建 <div id="ctx-top"> → initCtxViews() 创建 WebGLRenderer → canvas 挂载
取消选择: v-if=false → Vue 销毁 <div id="ctx-top"> → canvas 被 GC
但 ctxViews[] 仍持有旧 renderer 引用!
重新选中: v-if=true → Vue 创建新的 <div id="ctx-top">
selectAnnotation() 检查 ctxViews.length !== 0 → 跳过 initCtxViews()
→ 旧 renderer 指向已销毁的 canvas → 黑屏
时序图:
Vue 响应式 DOM 树 Three.js 对象
─────────── ────── ──────────────
selectedId = id1 创建 #ctx-top initCtxViews() → ctxViews = [{renderer, ...}]
│ │ │
selectedId = null 销毁 #ctx-top ctxViews 未清理 (stale references!)
│ │ │
selectedId = id2 创建新 #ctx-top ctxViews.length > 0 → 不重新初始化
│ │
新 DOM ≠ 旧 canvas → 渲染到幽灵 canvas → 黑屏
修复方案:
<!-- 正确写法:v-show 只切换 display:none,DOM 永远存在 -->
<div v-show="selectedAnnotationId" class="ctx-views-panel">配合 resizeCtxViews():当 display: none → block 时,clientWidth/Height 从 0 恢复,需要重新 renderer.setSize():
function selectAnnotation(id) {
selectedAnnotationId.value = id
nextTick(() => {
if (ctxViews.length === 0) {
initCtxViews() // 首次创建
} else {
resizeCtxViews() // v-show 切换后重新计算尺寸
}
updateClipPlanes()
updateCtxViewCameras()
})
}铁律: 当 DOM 元素承载 WebGL 上下文(<canvas>)时,永远不要用 v-if 控制其显隐。WebGL context 与 canvas DOM 元素绑定,元素被销毁 = context 丢失 = 不可恢复。
严重程度: P1 (视觉干扰)
现象: AxesHelper 和 TransformControls 的巨大箭头出现在三视图中,遮挡点云。
初始方案(部分有效):
// 将辅助对象放到 Layer 1
axesHelper.layers.set(1) // 只在 Layer 1
grid.layers.set(1) // 只在 Layer 1
// 主相机看 Layer 0 + 1
camera.layers.enable(1)
bevCamera.layers.enable(1)
// 三视图相机只看 Layer 0(默认)—— 看不到 Layer 1 的对象AxesHelper 和 GridHelper 成功隐藏。但 TransformControls 仍然出现!
根因:
Three.js 的 layers 系统有一个容易被忽略的特性:子节点不继承父节点的 layer 设置。
const tcHelper = transformControls.getHelper()
tcHelper.layers.set(1) // 只设置了根节点
// 但 TransformControls 内部实现是这样的:
class TransformControlsGizmo {
constructor() {
// 每次 attach/mode change 时动态创建子节点
const arrow = new THREE.Mesh(...)
arrow.layers.set(0) // 新子节点默认 Layer 0!
this.add(arrow)
}
}TransformControls 在每次切换模式、attach 新对象时会动态创建和销毁子节点。这些子节点自动回到 Layer 0,恰好能被三视图相机看到。
最终修复方案: 放弃 layers,改用渲染前后的 visibility 切换:
function animateCtxViews() {
ctxAnimFrameId = requestAnimationFrame(animateCtxViews)
// 渲染三视图前:隐藏 gizmo
const tcHelper = transformControls.getHelper()
if (tcHelper) tcHelper.visible = false
for (const cv of ctxViews) {
cv.renderer.render(scene, cv.camera)
}
// 渲染完毕:恢复 gizmo
if (tcHelper) tcHelper.visible = true
}visible = false 作用于整个子树(Three.js 的 visible 会递归影响所有后代),而且不受动态子节点的影响。
经验: Three.js layers 系统适用于静态场景图。对于动态创建子节点的控件(TransformControls、各种 Helper),改用 visible 切换更可靠。
严重程度: P0 (核心功能不可用)
现象: 选中 box 后,三视图的 Top/Side/Front 面板全部黑色,没有任何点云或框。
根因:
正交相机位于距 box 中心 10 个单位远的位置:
cv.camera.position.set(cx, cy + 10, cz) // Top view: 相机在 box 上方 10 米但 near/far 设置为:
// 错误写法 —— near/far 相对于相机位置!
cv.camera.near = -clipDepth / 2 // 例如 -0.65
cv.camera.far = clipDepth / 2 // 例如 +0.65正交相机的可见范围 = [camera.position + near, camera.position + far] 沿视线方向:
相机 (y = cy + 10)
●
│
可见范围 near=-0.65 ─│─ far=+0.65 ← 只能看到 y ∈ [9.35, 10.65]
│
│
│ ← 完全空白!box 在 y = cy (距这里 10m 远)
│
│
box 中心 (y = cy)
box 在 y = cy,但相机只渲染 y ∈ [cy + 9.35, cy + 10.65] 的内容 —— 这个范围内什么都没有。
修复:
const CAM_DIST = 10
cv.camera.near = CAM_DIST - halfDepth // 例如 10 - 0.9 = 9.1
cv.camera.far = CAM_DIST + halfDepth // 例如 10 + 0.9 = 10.9这样可见范围就正确包围了 box 中心:
相机 (y = cy + 10)
●
│
near ──│── 9.1 单位深 ← 可见范围开始
│
│
│
box 中心 ← 在可见范围内!
│
far ──│── 10.9 单位深 ← 可见范围结束
铁律: 正交相机的 near 和 far 是相对于相机自身沿视线方向的距离,不是世界坐标!写 near/far 时永远要问自己:「相机在哪?目标在哪?距离是多少?」
| 维度 | 课程作业 | 本项目 (工业级) |
|---|---|---|
| 渲染器数量 | 1 个 | 4 个 (主视图 + 3 个三视图) |
| 内存管理 | 依赖 GC | 手动 dispose() geometry/material/renderer |
| 坐标系 | 单一 | LiDAR ↔ Three.js 双向转换,每个环节需严格标注 |
| 相机 | 1 个透视 | 透视 + BEV 正交 + 3 个三视图正交,各有独立控制器 |
| 交互 | 鼠标点击 | Raycaster + TransformControls + 三视图拖拽 + 键盘快捷键 |
| 裁剪 | 无 | 6 个 WebGL Clipping Planes 动态跟随标注框 |
| 数据持久化 | console.log() |
FastAPI REST API + OpenPCDet 标准格式 |
Three.js 对象不会被 JavaScript GC 自动释放 GPU 资源。必须手动调用 dispose():
// 切换点云时必须释放旧的 GPU 资源
if (currentPoints) {
scene.remove(currentPoints)
currentPoints.geometry.dispose() // 释放 GPU 顶点缓冲
currentPoints.material.dispose() // 释放 GPU 纹理/着色器
}
// 删除标注框
function removeBoxMesh(id) {
const mesh = boxMeshes.get(id)
scene.remove(mesh)
mesh.geometry.dispose()
mesh.material.dispose()
boxMeshes.delete(id)
}
// 组件卸载时全面清理
onBeforeUnmount(() => {
cancelAnimationFrame(animFrameId)
transformControls.dispose()
orbitControls.dispose()
renderer.dispose()
renderer.domElement.remove()
disposeCtxViews() // 3个独立渲染器
})如果不做这些清理,每次切换文件或刷新页面都会泄漏 GPU 内存,在标注大量文件后浏览器会崩溃。
三视图拖拽交互有一个经典陷阱:事件绑定在容器 <div> 上,但用户实际点击的是 <canvas> 子元素。
DOM 结构:
<div id="ctx-top" class="ctx-view-container"> ← Vue @pointerdown 绑定在这里
<canvas width="320" height="150" /> ← 用户实际点击的是这里
</div>
问题:setPointerCapture(event.pointerId) 必须在收到 pointerdown 事件的元素上调用。如果事件绑定在 div、但实际 target 是 canvas,后续的 pointermove/pointerup 会丢失。
修复:直接绑定到 canvas 元素:
const canvas = ctxRenderer.domElement
canvas.addEventListener('pointerdown', (e) => onSubViewPointerDown(e, name))
canvas.addEventListener('pointermove', onSubViewPointerMove)
canvas.addEventListener('pointerup', onSubViewPointerUp)
function onSubViewPointerDown(event, viewName) {
event.target.setPointerCapture(event.pointerId) // 在 canvas 上捕获
}将鼠标拖拽的像素位移转换为三维世界坐标中的平移量:
// 正交相机的 frustum 宽度 = 世界空间中可见的范围
const frustumW = cv.camera.right - cv.camera.left
const frustumH = cv.camera.top - cv.camera.bottom
// 像素位移 → 世界位移 = 像素比例 × frustum 尺寸
const worldDx = (dx / viewportWidth) * frustumW
const worldDy = (dy / viewportHeight) * frustumH然后根据不同视图方向映射到不同的世界轴:
Top (俯视): screen X → world +X, screen Y → world +Z
Side (侧视): screen X → world -Z, screen Y → world -Y
Front(正视): screen X → world -X, screen Y → world -Y
符号的正负取决于相机的朝向和 up 向量。这部分必须对照相机设置手动推导,画图是最好的验证方法。
当相机与 target 之间的距离趋近于 0 时,OrbitControls 的内部运算会产生数值不稳定:
- Dolly (zoom): 按比例缩放距离 → 距离越小缩放量越微小 → 感觉「卡住」
- Pan: 平移速度与距离成正比 → 距离为 0 时无法平移
修复很简单:
orbitControls.minDistance = 0.5 // 永远不允许相机贴到 target 上
orbitControls.zoomSpeed = 2.0 // 提升响应速度
orbitControls.panSpeed = 1.5我们选择 OpenPCDet 的 .txt 格式而非 JSON,原因是:
# OpenPCDet label 格式 (每行一个物体):
x y z dx dy dz heading class_name
1.2340 -0.5670 0.1500 0.2000 0.2000 0.3000 0.7854 Cone
- 与训练管线零摩擦对接: OpenPCDet 的
kitti_dataset.py可以直接读取此格式 - 人类可读: 用
cat就能检查标注结果 - Git 友好: 纯文本 diff 清晰
- 前端 annotation 对象已用 LiDAR 坐标存储: 保存时无需再做坐标转换
| 快捷键 | 功能 |
|---|---|
Shift + Click |
在点击位置创建锥桶标注框 |
G |
切换到平移模式 |
R |
切换到旋转 (yaw) 模式 |
V |
切换 3D / BEV 视图 |
Delete / Backspace |
删除选中标注框 |
Esc |
取消选择 |
Ctrl + S |
保存标注 |
bitfsd-annotator/
├── frontend/
│ ├── src/
│ │ ├── App.vue ← 全部 Three.js + Vue 逻辑 (~1240 行)
│ │ ├── style.css ← 布局 + 三视图样式
│ │ └── main.js ← Vue app 入口
│ └── vite.config.js ← 开发代理 /api, /data → :8000
├── backend/
│ └── main.py ← FastAPI: 文件列表 + 标签 CRUD
└── data/
└── test/
├── lidar/ ← .pcd 点云文件
└── label/ ← .txt 标注文件 (OpenPCDet 格式)
"The best way to understand 3D graphics is to get every coordinate transform wrong at least once." —— 本项目开发历程的真实写照