Skip to content

Latest commit

 

History

History
710 lines (538 loc) · 25.6 KB

File metadata and controls

710 lines (538 loc) · 25.6 KB

BITFSD-Annotator: 知识架构与 Bug 复盘文档

面向读者: 具备基础编程能力、希望系统掌握「全栈三维标注工具」工程实践的 Formula Student 队员

技术栈: Vue 3 + Vite | Three.js r181 | FastAPI | OpenPCDet 标签格式


目录

  1. 系统架构大图
  2. 硬核三维几何与数学
  3. 经典 Bug 复盘与避坑指南
  4. 工业级前端开发心得

1. 系统架构大图

1.1 三层架构总览

┌─────────────────────────────────────────────────────────────────┐
│                        用户浏览器                                │
│  ┌──────────┐  ┌────────────────────────┐  ┌─────────────────┐  │
│  │ 文件列表  │  │   主 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格式)          │
└─────────────────────────────────────────────────────────────────┘

1.2 端到端数据流

从点击一个 .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

1.3 状态管理设计:Vue 响应式 vs Three.js 命令式

本项目最核心的架构决策是如何协调两套完全不同的编程范式:

维度 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
})

1.4 为什么用 4 个 WebGLRenderer 而不是 1 个?

许多教程用 renderer.setViewport() + renderer.setScissor() 实现多视口。但我们必须使用独立 Renderer,核心原因是 WebGL Clipping Planes

// 主渲染器:显示完整点云,不裁剪
renderer.localClippingEnabled = false

// 三视图渲染器:只显示标注框附近的点云
ctxRenderer.localClippingEnabled = true
currentPoints.material.clippingPlanes = clipPlanes  // 6 个裁剪平面

localClippingEnabledRenderer 级别 的开关。同一个 Renderer 无法对同一个 Material 在不同视口中施加不同的裁剪行为。这就是为什么参考项目 SUSTechPOINTS 也采用了独立 Renderer 架构。


2. 硬核三维几何与数学

2.1 坐标系转换:LiDAR (Z-up) ↔ Three.js (Y-up)

这是整个项目中最容易出错、影响最广泛的数学问题。

两套坐标系定义:

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

教训: 坐标映射必须在项目最初期就确定,并在代码中用注释严格标注每个转换点。一旦搞混,所有视图、所有交互、所有保存/加载全部会错。

2.2 光线投射 (Raycasting):从 2D 屏幕点击到 3D 点云

用户点击屏幕的一个像素,如何找到点云中对应的三维点?

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 的阈值既不会错过锥桶表面的稀疏点,也不会频繁误触。

2.3 Clipping Planes 数学:用 6 个平面「切」出一个盒子

三视图需要只显示标注框附近的点云。我们用 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 是留给周围上下文点云的余量。

2.4 正交视图的 near/far 裁剪策略

正交相机的 nearfar 不是从原点算的,而是从相机位置沿视线方向的偏移量。

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

2.5 自动朝向计算 (Auto-Yaw)

放置锥桶时自动计算 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) 方向的夹角,使得锥桶的正面始终朝向传感器。


3. 经典 Bug 复盘与避坑指南

Bug 1: Vue v-if vs v-show —— WebGL Context 丢失导致黑屏

严重程度: P0 (核心功能不可用)

复现路径:

  1. 选中一个 box → 三视图正常显示
  2. 按 Esc 取消选择 → 三视图消失(预期)
  3. 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 丢失 = 不可恢复。


Bug 2: Three.js Layers 系统 —— TransformControls 子节点逃逸

严重程度: 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 切换更可靠。


Bug 3: 正交相机 near/far 计算错误 —— 三视图全黑

严重程度: 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 单位深  ← 可见范围结束

铁律: 正交相机的 nearfar 是相对于相机自身沿视线方向的距离,不是世界坐标!写 near/far 时永远要问自己:「相机在哪?目标在哪?距离是多少?」


4. 工业级前端开发心得

4.1 与「课程作业」的本质区别

维度 课程作业 本项目 (工业级)
渲染器数量 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 标准格式

4.2 WebGL 内存管理

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 内存,在标注大量文件后浏览器会崩溃。

4.3 事件隔离与指针捕获

三视图拖拽交互有一个经典陷阱:事件绑定在容器 <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 上捕获
}

4.4 三视图拖拽的像素-世界坐标映射

将鼠标拖拽的像素位移转换为三维世界坐标中的平移量:

// 正交相机的 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 向量。这部分必须对照相机设置手动推导,画图是最好的验证方法。

4.5 OrbitControls 的「死锁」问题

当相机与 target 之间的距离趋近于 0 时,OrbitControls 的内部运算会产生数值不稳定:

  • Dolly (zoom): 按比例缩放距离 → 距离越小缩放量越微小 → 感觉「卡住」
  • Pan: 平移速度与距离成正比 → 距离为 0 时无法平移

修复很简单:

orbitControls.minDistance = 0.5  // 永远不允许相机贴到 target 上
orbitControls.zoomSpeed = 2.0   // 提升响应速度
orbitControls.panSpeed = 1.5

4.6 保存格式的工程考量

我们选择 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
  1. 与训练管线零摩擦对接: OpenPCDet 的 kitti_dataset.py 可以直接读取此格式
  2. 人类可读: 用 cat 就能检查标注结果
  3. Git 友好: 纯文本 diff 清晰
  4. 前端 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." —— 本项目开发历程的真实写照