Personal Knowledge Base

A long-term research and learning notebook for posts, notes, papers, projects, and research directions.

Skip to content
Back to RiskAffordance overview

数据处理阶段

目的:把 nuScenes 的原始点云变成模型能吃的、规整的三维网格:一个固定的"自车为中心的世界",告诉模型每个小格子里有没有东西、离自车多远。

76 min read

子任务 A:占用构建(build_occupancy

目的:把 nuScenes 的原始点云变成模型能吃的、规整的三维网格:一个固定的"自车为中心的世界",告诉模型每个小格子里有没有东西、离自车多远。

输入

  • nuScenes 数据集的 LIDAR_TOP 点云(车顶激光雷达);
  • 按官方 train/val split 划分。

处理流程与设计理由

  1. 每帧对齐到该帧的 ego 坐标系(x 前、y 左、z 上)。为什么不能用一个统一坐标系?因为占用是"自车视角的感知"——模型要回答的是"我周围有没有障碍",一切以自车为原点最自然,也让后续 ρ/v 场(都是相对 ego 定义的)计算变得直接。

  2. 体素化:X/Y ∈ [-40, 40]m,Z ∈ [-2, 6]m,分辨率 0.4m → 200×200×20。

    • 0.4m 是在精度和算力之间的折中:再细了体素数爆炸,再粗了行人/栏杆这类小目标会被抹掉;
    • ±40m 的横向范围覆盖了绝大多数"可能对我构成威胁"的目标(更远的目标 TTC 再大、ρ 也衰减到接近 0,对先验没贡献);
    • Z 方向 [-2, 6]m 只需覆盖路面到卡车车顶,20 层足够。
  3. 两个通道,这是关键设计

    通道内容为什么需要它
    0占用 0/1基本的几何输入
    1距离分层:0空 / 1ego / 2近(<15m)/ 3中(15-30m)/ 4远(>30m)危险与距离强相关。把距离显式编码成输入通道,模型就不必自己从体素索引里"推"距离——这降低了学习难度,也让"远处的物体"和"近处的物体"在特征层面天然区分开。15m/30m 的切分与 ρ 的衰减尺度 d₀=20m 是配套的
  4. 序列化:keyframe(2Hz)滑动窗口 T=10。

    • 为什么 2Hz?nuScenes 的 keyframe 本身就是 2Hz(0.5s 一帧),3D box 标注只在 keyframe 上有,伪标签也要对齐 keyframe,所以占用序列跟着 keyframe 走,保证 X 和 Y 严格逐帧对齐;
    • 为什么 T=10?0.5s × 10 = 5 秒的历史窗口,足够模型观察到物体的运动趋势(ρ/v 本质是"物体逼近"的模式,单帧看不出来);
    • 不足 10 帧的序列(场景开头)直接丢弃,保证所有样本 shape 一致。
  5. 存储格式

    • 每个样本一个 {scene_token}_{sample_token}.npz,内容是 (T=10, C=2, 200, 200, 20) 的 uint8(省空间);
    • 一个 index.jsonl 记录每个文件的 scene_tokensample_tokens、时间戳、ego 位姿——这是后续所有阶段的"账本",调试时定位任何一帧都靠它。

输出data/occupancy/{split}/ 下的 npz 文件 + index.jsonl。trainval 全量已跑完:train 4419 序列 / val 1047 序列。

子任务 B:伪标签生成(gen_pseudo_labels

这是这一阶段真正的核心——先验模型要学的"危险"到底是什么,全在这里定义。

目的:回答两个问题,对应先验 H 的两个分量:

  • ρ(危险强度场):空间里每个体素有多危险?
  • v(危险方向场):危险的源头在哪个方向?

输入与信号源选择:信号源是 nuScenes 官方的 3D box + nusc.box_velocity(官方标注的物体速度)。

这里有一个明确的设计决策:不从体素流(voxel flow)估计速度,而用官方标注。因为体素流是从稀疏点云估计的,噪声大;而官方 box 和速度是人工标注+跟踪的结果,精度高。伪标签是监督信号,监督信号错了模型就学歪——精度优先于自动化。

公式链条:从 box 到 ρ 场(逐步拆开)

  1. 算"逼近速度" closing_speed

    closing_speed = max(0, -dot(v_rel, p̂_rel))
    • v_rel 是相对速度(物体速度 − ego 速度),p̂_rel 是 ego→物体的单位向量;
    • 点积取负、再 clamp 到非负,得到的就是"两者连线上互相靠近的速率";
    • closing < 0.1 m/s 时直接认为 TTC=∞(避免除零,也符合物理直觉:几乎不逼近就没有碰撞时间可言)。
  2. 算 TTC 和危险幅度 a

    a = exp(-d/20m) × clamp(1 - TTC/3s, 0, 1)

    这是整个项目最重要的公式,两个因子缺一不可:

    • exp(-d/d₀),d₀=20m:距离衰减。50m 外一个静止物体即使 TTC 很小(ego 高速直行),衰减后贡献也有限;反之近处目标权重急剧上升。d₀=20m 与距离分层的 15/30m 切分是同一尺度体系;
    • clamp(1 − TTC/TTC₀),TTC₀=3s:碰撞时间紧迫度。3 秒内会撞上才开始"危险",越近越大;TTC > 3s 就是 0。这就是为什么安全帧的 ρ 全是零——这是设计使然,不是 bug(下面陷阱部分细讲)。
  3. 静态折扣 ×0.5。如果物体本身几乎不动(|v_obj| < 0.2 m/s)且 closing 全部来自 ego 自运动(比如 ego 驶向一辆停着的车),危险幅度打五折。理由:静止物体不会主动改变轨迹,风险低于同距离同 TTC 的运动物体(比如前车急刹)。

  4. ρ 栅格化

    • 每个风险物体以 box 中心为核、画一个二维高斯(sigma 与 box 尺寸成正比——大车影响范围大),沿 box 的 z 范围竖直填充;
    • 多物体重叠时逐体素取 max,不是 sum。
    • 为什么 max 不 sum?因为 ρ 是"危险强度",语义上应该饱和在 [0,1]:两辆车在同一区域,危险程度是"最危险的那个"决定的,不是叠加的。取 sum 会让 ρ 溢出物理含义,还会让密集车流的区域不成比例地主导损失。
  5. v 栅格化

    • 高斯核覆盖的体素内,v = normalize(p_ego − p_source),即风险源指向 ego 的单位向量(BEV 平面,z 分量置 0);
    • 一个体素被多个核覆盖时,归属 ρ 贡献最大的那个源——方向必须指向真正的主威胁;
    • 为什么 z=0?驾驶决策是平面的,方向场服务于"往哪躲",竖直分量没有决策意义。

输出data/pseudo_labels/{split}/,每个序列三个数组:

  • rho:(T,1,200,200,20) float16
  • vel:(T,3,200,200,20) float16
  • mask:(T,1,200,200,20) bool,定义为 rho > 0.05

mask 的作用:ρ 场 99% 以上的体素是零(实测非零体素只占 0.71%),方向场 v 在零区域根本没有定义。训练时 v 只在 mask=1 的体素上监督(τ=0.05 即此阈值),否则模型会被海量"无意义零方向"带偏。

踩过的坑与解决方案

这些全是实跑时真实遇到的问题,按重要性排:

  1. 坑 1:地面扫描环淹没近处目标
    • 现象:BEV 投影上出现以 ego 为中心的同心圆亮环,把近处真目标盖掉。
    • 原因:LiDAR 装在车顶约 1.8m 高,扫到地面的点在 lidar 系 z≈-1.8m,这些点全是"假占用"。
    • 修复:转到 ego 系后剔除 z < -1.4m 的点(阈值写进 yaml 可调)。留下的少量真实低矮障碍不受影响。
  2. 坑 2:异常点污染出一条贯穿 ego 的亮线
    • 原因:两个——NaN/Inf 点 astype(int) 后变成 0,全堆到 x=0 列;越界点被 clip 到边界索引,堆到网格边缘。
    • 修复:处理顺序必须严格是 先剔 NaN/Inf → 越界点直接丢弃(禁止 clip)→ 最后才取整。这个顺序写死进代码,顺序错了 bug 就回来。
  3. 坑 3:ego 自扫描回波。点云里有打到自己车身的回波。处理分两步:先按车身矩形过滤掉自回波点,再主动按真实车身尺寸 4.6×2.0×1.8m 和 ego yaw 把自车盖回体素里,标为距离层的"1=ego"。因为自车确实是"被占用"的,模型需要明确知道自己这个刚体在哪。
  4. 坑 4:方向没法肉眼确认。单看体素图根本分不清哪边是前。所以可视化规范里强制要求:每张检查图必须叠加 ego 朝向箭头 + GT 3D box 的 BEV 红框投影,用红框和体素亮块的重合度来验证整个坐标变换链条对不对。这条后来成了项目"可视化检查图是阶段分水岭"制度的源头。
  5. 坑 5:GT 框里没有点 ≠ bug。有时候一个标注框内一个 LiDAR 点都没有——单帧稀疏+遮挡,正常现象。验证方法不是猜,而是逐框统计点数与官方 num_lidar_pts 对拍:mini 集 106/106 完全一致,证明坐标变换、帧对齐都没错,可以排除帧错配。
  6. 坑 6:TTC₀=3s 导致大量全零 ρ 帧。安全帧里没有任何 3 秒内会碰撞的物体,ρ 全零。实测非零帧占比 59.68%。结论:这是设计使然——模型必须见过"不危险"的样本才能学会不误报(必要负样本)。并定了量化红线:占比 <15% 才用损失加权处理,<5% 才考虑放宽 TTC₀。现在 59.68% 远在线内,不动。

另外两个工程性的坑:nuscenes-devkit 要求 numpy<1.24np.float 弃用);累积 sweep 的可视化只用于人眼检查,不进训练产物。

验收

项目铁律是"pytest 全绿 + 可视化图人工确认,图不过不进下一阶段"。这一阶段留下的验收记录:

结果
坐标变换正确性与官方 num_lidar_pts 对拍 106/106 一致
单元测试test_voxel_ops.py 10 例 + test_pseudo_labels.py 7 例全过
伪标签统计合理性平均风险物体 2.353 个/帧、ρ 非零体素 0.71%、v 方向范数 ∈ [0.99966, 1.00032](单位向量校验)、mask 一致性零冲突
可视化三标准① 该亮的亮(逼近的目标高亮)② 该暗的暗(远离/平行接近背景)③ 箭头指向 ego 且近处方向相干

这三个可视化标准其实就是 ρ 和 v 的语义定义的人话版——公式对不对,最终靠眼睛确认"危险的地方确实亮、危险箭头确实指着威胁来的方向"。

数据处理阶段小结

数据处理阶段 = 把 nuScenes 的"感知原始材料"(点云)和"标注知识"(box+速度)分别加工成模型的输入 X(双通道占用序列)和监督 Y(ρ 强度场 + v 方向场 + 监督掩码);所有公式参数(d₀=20m、TTC₀=3s、静态折扣 0.5、τ=0.05)都服务于一个语义目标——"危险 = 近 + 快逼近";而 ρ 和 v 这两个场之所以要单独学,是因为它们是 bbox 表达不了的信息(连续的方向场和强度场),这也是后面消融实验(affordance vs bbox 条件)的立论基础。