子任务 A:占用构建(build_occupancy)
目的:把 nuScenes 的原始点云变成模型能吃的、规整的三维网格:一个固定的"自车为中心的世界",告诉模型每个小格子里有没有东西、离自车多远。
输入:
- nuScenes 数据集的
LIDAR_TOP点云(车顶激光雷达); - 按官方 train/val split 划分。
处理流程与设计理由:
-
每帧对齐到该帧的 ego 坐标系(x 前、y 左、z 上)。为什么不能用一个统一坐标系?因为占用是"自车视角的感知"——模型要回答的是"我周围有没有障碍",一切以自车为原点最自然,也让后续 ρ/v 场(都是相对 ego 定义的)计算变得直接。
-
体素化:X/Y ∈ [-40, 40]m,Z ∈ [-2, 6]m,分辨率 0.4m → 200×200×20。
- 0.4m 是在精度和算力之间的折中:再细了体素数爆炸,再粗了行人/栏杆这类小目标会被抹掉;
- ±40m 的横向范围覆盖了绝大多数"可能对我构成威胁"的目标(更远的目标 TTC 再大、ρ 也衰减到接近 0,对先验没贡献);
- Z 方向 [-2, 6]m 只需覆盖路面到卡车车顶,20 层足够。
-
两个通道,这是关键设计:
通道 内容 为什么需要它 0 占用 0/1 基本的几何输入 1 距离分层:0空 / 1ego / 2近(<15m)/ 3中(15-30m)/ 4远(>30m) 危险与距离强相关。把距离显式编码成输入通道,模型就不必自己从体素索引里"推"距离——这降低了学习难度,也让"远处的物体"和"近处的物体"在特征层面天然区分开。15m/30m 的切分与 ρ 的衰减尺度 d₀=20m 是配套的 -
序列化: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 一致。
-
存储格式:
- 每个样本一个
{scene_token}_{sample_token}.npz,内容是 (T=10, C=2, 200, 200, 20) 的 uint8(省空间); - 一个
index.jsonl记录每个文件的scene_token、sample_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 到 ρ 场(逐步拆开):
-
算"逼近速度"
closing_speed:closing_speed = max(0, -dot(v_rel, p̂_rel))v_rel是相对速度(物体速度 − ego 速度),p̂_rel是 ego→物体的单位向量;- 点积取负、再 clamp 到非负,得到的就是"两者连线上互相靠近的速率";
closing < 0.1 m/s时直接认为 TTC=∞(避免除零,也符合物理直觉:几乎不逼近就没有碰撞时间可言)。
-
算 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(下面陷阱部分细讲)。
-
静态折扣 ×0.5。如果物体本身几乎不动(
|v_obj| < 0.2 m/s)且 closing 全部来自 ego 自运动(比如 ego 驶向一辆停着的车),危险幅度打五折。理由:静止物体不会主动改变轨迹,风险低于同距离同 TTC 的运动物体(比如前车急刹)。 -
ρ 栅格化。
- 每个风险物体以 box 中心为核、画一个二维高斯(sigma 与 box 尺寸成正比——大车影响范围大),沿 box 的 z 范围竖直填充;
- 多物体重叠时逐体素取 max,不是 sum。
- 为什么 max 不 sum?因为 ρ 是"危险强度",语义上应该饱和在 [0,1]:两辆车在同一区域,危险程度是"最危险的那个"决定的,不是叠加的。取 sum 会让 ρ 溢出物理含义,还会让密集车流的区域不成比例地主导损失。
-
v 栅格化。
- 高斯核覆盖的体素内,
v = normalize(p_ego − p_source),即风险源指向 ego 的单位向量(BEV 平面,z 分量置 0); - 一个体素被多个核覆盖时,归属 ρ 贡献最大的那个源——方向必须指向真正的主威胁;
- 为什么 z=0?驾驶决策是平面的,方向场服务于"往哪躲",竖直分量没有决策意义。
- 高斯核覆盖的体素内,
输出:data/pseudo_labels/{split}/,每个序列三个数组:
rho:(T,1,200,200,20) float16vel:(T,3,200,200,20) float16mask:(T,1,200,200,20) bool,定义为rho > 0.05
mask 的作用:ρ 场 99% 以上的体素是零(实测非零体素只占 0.71%),方向场 v 在零区域根本没有定义。训练时 v 只在 mask=1 的体素上监督(τ=0.05 即此阈值),否则模型会被海量"无意义零方向"带偏。
踩过的坑与解决方案
这些全是实跑时真实遇到的问题,按重要性排:
- 坑 1:地面扫描环淹没近处目标。
- 现象:BEV 投影上出现以 ego 为中心的同心圆亮环,把近处真目标盖掉。
- 原因:LiDAR 装在车顶约 1.8m 高,扫到地面的点在 lidar 系 z≈-1.8m,这些点全是"假占用"。
- 修复:转到 ego 系后剔除
z < -1.4m的点(阈值写进 yaml 可调)。留下的少量真实低矮障碍不受影响。
- 坑 2:异常点污染出一条贯穿 ego 的亮线。
- 原因:两个——NaN/Inf 点
astype(int)后变成 0,全堆到 x=0 列;越界点被 clip 到边界索引,堆到网格边缘。 - 修复:处理顺序必须严格是 先剔 NaN/Inf → 越界点直接丢弃(禁止 clip)→ 最后才取整。这个顺序写死进代码,顺序错了 bug 就回来。
- 原因:两个——NaN/Inf 点
- 坑 3:ego 自扫描回波。点云里有打到自己车身的回波。处理分两步:先按车身矩形过滤掉自回波点,再主动按真实车身尺寸 4.6×2.0×1.8m 和 ego yaw 把自车盖回体素里,标为距离层的"1=ego"。因为自车确实是"被占用"的,模型需要明确知道自己这个刚体在哪。
- 坑 4:方向没法肉眼确认。单看体素图根本分不清哪边是前。所以可视化规范里强制要求:每张检查图必须叠加 ego 朝向箭头 + GT 3D box 的 BEV 红框投影,用红框和体素亮块的重合度来验证整个坐标变换链条对不对。这条后来成了项目"可视化检查图是阶段分水岭"制度的源头。
- 坑 5:GT 框里没有点 ≠ bug。有时候一个标注框内一个 LiDAR 点都没有——单帧稀疏+遮挡,正常现象。验证方法不是猜,而是逐框统计点数与官方
num_lidar_pts对拍:mini 集 106/106 完全一致,证明坐标变换、帧对齐都没错,可以排除帧错配。 - 坑 6:TTC₀=3s 导致大量全零 ρ 帧。安全帧里没有任何 3 秒内会碰撞的物体,ρ 全零。实测非零帧占比 59.68%。结论:这是设计使然——模型必须见过"不危险"的样本才能学会不误报(必要负样本)。并定了量化红线:占比
<15%才用损失加权处理,<5%才考虑放宽 TTC₀。现在 59.68% 远在线内,不动。
另外两个工程性的坑:nuscenes-devkit 要求 numpy<1.24(np.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 条件)的立论基础。