1. 把“合集”当成一个工程项目来做
做自动驾驶合集做到第 24 期,最大的感受不是内容不够,而是内容太多、太散。前二十几期里堆过标定笔记、堆过规划调参记录、堆过仿真环境搭建的踩坑清单,也堆过一堆“看起来很高大上但半年没人点开”的论文摘要。到了第 24 期,如果还按老办法继续加链接,这份合集就会变成一个只增不减的杂物间——新读者进来找不到入口,老读者回来找不到上次看的那一条。
所以这一期我做的第一件事是重构,而不是续写。自动驾驶合集这类长期更新的东西,真正的价值不在“收了多少”,而在“能不能被复用”。一份能被复用的合集,至少得满足三个条件:任何一条内容都能被一句话说清楚它解决什么问题;任何一条内容都带可验证的输入输出;任何一条内容都留了失败记录。缺了第三条,合集就只是成功案例展览,读者照着做一遍照样翻车。
这份内容适合谁?刚入行、被感知和规划的名词绕晕的同学,可以把它当路线图用,按章节顺序走一遍,能建立起“数据从传感器进来、到车轮转出去”的完整链路认知;已经在一线做模块的同学,可以挑第 3 章和第 5 章看,那里是参数怎么定、问题怎么查的实操部分。下面所有内容都按“为什么这么选、具体怎么做、哪里容易翻车”三段式展开,能抄的直接抄,不能抄的至少知道方向。
1.1 第 24 期为什么要重构目录,而不是继续往下堆
信息堆到一定量级后,检索成本会指数上升。我做过一个很粗糙的统计:前 23 期里,同一个话题(比如时间同步)在不同期里被重复讲了四次,但每次的结论都不完全一致,早期那版甚至是错的。读者如果按时间顺序读,会先学到错的东西,再学到对的东西,中间没有任何标记告诉他“前面那条作废了”。这是合集类内容最隐蔽的坑——它不告诉你哪条过期了。
重构的核心动作是把“按时间组织”改成“按问题组织”。我用的是一套三层索引:
| 索引层级 | 回答的问题 | 举例 | 维护频率 |
|---|---|---|---|
| 第一层:问题域 | 我遇到的是哪一类问题 | 定位漂移、跟车顿挫、夜间误检 | 半年一次 |
| 第二层:模块归属 | 这个问题落在哪条链路上 | 标定、时间同步、规划、控制 | 季度一次 |
| 第三层:可复现条目 | 具体怎么做、怎么验、怎么失败 | 某条外参复核流程 | 每次更新 |
三层索引立起来之后,每条内容都必须挂到第三层,挂不上去的就直接删。这条规则很粗暴,但极其有效——我删掉了将近三分之一的历史条目,理由是“它只能挂在第一层,说不清具体怎么做”。能被说清楚的才值得留,说不清楚的不是知识,是情绪。
提示:合集做久了,一定要有“删除机制”。只增不减的合集,三个月后自己都不想打开。
1.2 四层主线拆解:为什么按数据流分层,而不是按算法名分层
按算法名分层是新手最容易犯的错。目录写成“卡尔曼滤波”“卡尔曼滤波变体”“粒子滤波”“图优化”,看起来很学术,但实际工作中没人这么找东西。真实场景是:车在路口停下来不动了,你得判断是感知没看到红绿灯、定位飘了、规划找不到可行路径,还是控制没收到指令。这时候你需要的是按数据流组织的目录。
| 层级 | 输入 | 输出 | 关键验收指标 | 高频翻车点 |
|---|---|---|---|---|
| 感知 | 图像、点云、毫米波回波 | 目标框、可行驶区域、车道线 | 召回率、误检率、时延 | 时间戳对不齐、标定漂移 |
| 定位 | 感知结果、惯导、地图 | 位姿(位置+姿态) | 横向误差、航向误差 | 地图版本错配、退化场景 |
| 决策规划 | 位姿、目标、地图、交规 | 轨迹(点序列+速度) | 舒适性、安全性、通行效率 | 边界条件没覆盖、参数过拟合 |
| 控制执行 | 轨迹、车辆状态 | 油门、刹车、转向 | 跟踪误差、超调量 | 执行器延迟、标定不准 |
按这条主线走,好处是任何人拿到一个现象,都能顺着箭头往回倒推。我在合集里给每一层都配了一张“入口表”,表里只有三列:这一层最常见的五个现象、最可能的原因、对应的排查章节号。这套东西听着笨,但新人上手速度能快一倍以上。
2. 感知与定位:合集里最容易注水的两块
感知和定位是自动驾驶里最容易被写“水”的部分,因为这两块的外行门槛高、验证门槛也高,写的人容易只写结论不写过程。一份负责任的合集,在这两块必须把“数据怎么对齐”和“误差怎么量化”讲透,否则读者拿到的只是一堆名词。
2.1 相机与激光雷达的数据对齐,本质是三件事
很多人以为标定就是求一个外参矩阵,实际上完整的数据对齐要拆成三件独立的事:空间对齐(外参)、时间对齐(同步)、语义对齐(谁是谁)。三件事任何一件没做好,融合结果都会飘,而且表现完全不同,必须分开排查。
空间对齐的核心是外参。外参标定的方法大致分两类:依赖标定物的(标定板、靶标)和不依赖标定物的(基于场景特征、基于运动)。前者精度高、可复现,适合出厂和定期复核;后者灵活,适合在线监测漂移。我个人的建议是两条腿走路——用标定物方法建立基准,用场景方法做日常监控,两者差值超过阈值就触发复核。
外参误差到底有多致命?做一个简单估算:一个 1 度的角度误差,在 30 米处的横向偏移是 30 × tan(1°) ≈ 0.52 米。听起来不到半米,但如果这个误差出现在窄路会车或者贴边停车场景,半米就是蹭与不蹭的区别。俯仰角误差更麻烦,它会让地面点云整体“翘起来”,直接把可行驶区域算错。所以合集里我写了一条硬规则:任何外参变更,必须在三个距离段(10 米、30 米、60 米)分别复核投影偏差,单段合格不算合格。
时间对齐比空间对齐更容易被忽略。相机一帧 30 毫秒到 50 毫秒,激光雷达一圈 100 毫秒,两个传感器各自打时间戳,中间只要差 20 毫秒,在 60 公里/小时(约 16.7 米/秒)下就是 0.33 米的空间错位。解决思路无非三种:硬件同步(共用触发源)、网络时间同步(精度到亚毫秒)、软件时间戳补偿(用运动模型反推)。前两种成本高但一劳永逸,第三种成本低但只能缓解。
下面这段是我常用来做时间戳体检的脚本,思路很简单:把两路数据的时间戳排序后求相邻差值,看分布有没有异常尖峰和空洞。
import numpy as np def stamp_health(cam_ts, lidar_ts, nominal_dt=0.05, tol=0.02): """检查两路传感器时间戳的健康度 cam_ts / lidar_ts: 秒为单位、单调递增的时间戳数组 nominal_dt: 名义上的对齐周期 tol: 允许的抖动上限 """ cam_ts = np.asarray(cam_ts, dtype=np.float64) lidar_ts = np.asarray(lidar_ts, dtype=np.float64) # 1) 各自的自洽性:相邻帧间隔是否稳定 d_cam = np.diff(cam_ts) d_lidar = np.diff(lidar_ts) # 2) 交叉对齐误差:每帧相机找最近的雷达帧 idx = np.searchsorted(lidar_ts, cam_ts) idx = np.clip(idx, 0, len(lidar_ts) - 1) left = np.abs(cam_ts - lidar_ts[np.maximum(idx - 1, 0)]) right = np.abs(lidar_ts[idx] - cam_ts) cross_err = np.minimum(left, right) report = { "cam_dt_mean": float(d_cam.mean()), "cam_dt_max_jitter": float(np.abs(d_cam - d_cam.mean()).max()), "lidar_dt_mean": float(d_lidar.mean()), "cross_err_mean": float(cross_err.mean()), "cross_err_p99": float(np.percentile(cross_err, 99)), "cross_err_over_tol": int((cross_err > tol).sum()), } return report # 用法:先跑一遍,看 cross_err_p99;超过一个相机帧周期就说明同步链路有问题这段脚本不解决同步问题,它只负责告诉你“有没有问题、问题多大”。我在实际项目里用它筛过一批数据,发现有一路相机的 p99 误差到了 80 毫秒,追下去是采集端写了两次时间戳,一次是驱动回调时间、一次是落盘时间,混用了。这种问题靠肉眼是绝对看不出来的。
2.2 三张表要一次立好:坐标系、时间基准、标定版本
合集里我反复强调一件事:上车之前先把三张表立起来,比调十个算法管用。三张表分别是坐标系表、时间基准表、标定版本表。
坐标系表要写清楚每个坐标系的名称、父节点、轴向定义、单位、原点位置,以及谁负责发布。常见坑是“同一辆车上有两套相机坐标系,轴向反了 180 度”,这种问题在单测里发现不了,一上融合就表现为目标左右镜像。轴向定义一定要白纸黑字写下来,别指望口头传承。
时间基准表要写清楚全系统的时间源是谁、同步方式是什么、各节点的期望偏差是多少。这里最容易出的事是“某个节点用了本地时钟”,在长时间跑数据时不明显,一跑几百秒就累积出漂移。
标定版本表是最容易被省略的一张。我的要求是:每次标定都必须生成一个版本号,包含标定日期、设备序列号、标定方法、复核偏差、操作人(用代号即可)。跑任何数据、复现任何问题时,先报版本号。这一点看着官僚,但它能省下大量“到底是不是标定变了”的扯皮时间。我遇到过最典型的一次:同一段数据两次跑出不同结果,查了两天,最后发现是两次跑之间有人做了在线标定,外参动了 0.3 度。
| 现象 | 最可能原因 | 快速验证方法 |
|---|---|---|
| 融合目标左右偏移 | 外参平移分量或轴向错误 | 拿静止标定物投影,看偏差方向是否一致 |
| 目标“忽前忽后” | 时间同步偏差或时间基准不一致 | 跑上面的时间戳体检脚本,看交叉误差分布 |
| 地面点云起伏 | 俯仰角外参错误 | 拟合地面平面,看法向量与理论值差多少 |
| 同一数据两次结果不同 | 标定版本或配置版本变更 | 比对标定版本号与配置文件哈希 |
| 远处目标检测断断续续 | 外参角度误差放大 + 点云稀疏 | 在 60 米处复核投影偏差 |
3. 决策规划与控制:从“能跑”到“跑得像人”
感知定位解决“我在哪、周围有什么”,规划控制解决“我该怎么动”。这一块最折磨人的地方在于:它没有绝对的正确答案。同一段路,十个人能给出十条都说得通的轨迹,所以评价体系比算法本身更重要。
3.1 规划层的四个可量化指标,先把尺子立起来
我在合集里把规划评价拆成四把尺子,每一把都能落到数字上:
第一把是舒适性。核心是加速度和加加速度(jerk)。纵向加速度一般控制在 ±2 m/s² 以内比较舒服,急刹可以放宽到 -4 到 -6 但必须短促;纵向 jerk 建议不超过 2 到 5 m/s³,横向 jerk 更敏感,控制在 0.5 到 1 m/s³ 会明显减少“甩”的感觉。举一个实际计算:从 0 加速到 36 公里/小时(10 米/秒),用时 2 秒,平均加速度 5 m/s²,这个值已经偏猛了,乘客会有明显推背感;如果同样的目标速度用 5 秒完成,平均加速度 2 m/s²,体感就柔和很多。手感上的差异,本质就是这条加速度曲线的斜率。
第二把是安全性。常用的是最小间距和碰撞时间(TTC)。跟车时距建议在 1.5 到 2.5 秒之间随速度调整,低速拥堵可以压缩到 1 秒出头,高速必须拉到 2 秒以上。TTC 低于 2 秒就该开始考虑减速,低于 1 秒基本属于危险区间。
第三把是通行效率。不能只看平均速度,那样会鼓励激进策略。我一般看两个量:单位距离的平均耗时,以及“无谓停车次数”——就是在没有让行义务的情况下停下来的次数。后者特别能反映规划是不是过于保守。
第四把是合法性。这一项是硬约束,不能拿来做权衡。压线、闯灯、逆行这些在仿真里必须直接判失败,不给任何分数补偿。
| 指标 | 推荐阈值 | 测量方式 | 超标后的处理方向 |
|---|---|---|---|
| 纵向加速度 | ±2 m/s²(常规) | 轨迹二阶差分 | 平滑速度曲线、拉长规划时域 |
| 纵向 jerk | ≤ 2~5 m/s³ | 轨迹三阶差分 | 加速度限幅 + 低通 |
| 横向 jerk | ≤ 0.5~1 m/s³ | 曲率变化率换算 | 限制曲率变化率 |
| 跟车时距 | 1.5~2.5 s | 间距 / 自车速度 | 调整期望时距参数 |
| 无谓停车次数 | 越少越好 | 人工标注统计 | 放宽让行判定条件 |
3.2 控制层:纯跟踪、PID、MPC 各自该在什么位置
控制层的选型我见过很多争论,其实答案取决于三个约束:车速范围、执行器精度、算力预算。
纯跟踪(Pure Pursuit)适合低速、路径平滑的场景。它的核心参数是前视距离,经验公式是前视距离 = k × 车速 + 最小前视距离,k 一般取 0.5 到 1.5,最小前视距离取 3 到 5 米。前视距离太小,车会“抖”,因为它盯着很近的点反复修正;前视距离太大,车会“抄近道”,弯道内侧切得厉害。下面是最小实现:
import math def pure_pursuit(pose, path, k=1.0, ld_min=4.0, wheelbase=2.7, max_steer=0.6): """pose: (x, y, yaw);path: [(x, y), ...] 按行驶方向排列""" x, y, yaw = pose v = pose[3] if len(pose) > 3 else 0.0 # 速度由外部传入 ld = k * abs(v) + ld_min # 前视距离随速度线性增长 # 在路径上找最近点,再向前搜索 ld 距离处的目标点 dists = [math.hypot(px - x, py - y) for px, py in path] i0 = int(min(range(len(dists)), key=lambda i: dists[i])) target = path[-1] for i in range(i0, len(path)): if math.hypot(path[i][0] - x, path[i][1] - y) >= ld: target = path[i] break dx, dy = target[0] - x, target[1] - y alpha = math.atan2(dy, dx) - yaw alpha = math.atan2(math.sin(alpha), math.cos(alpha)) # 归一化到 [-pi, pi] curvature = 2 * math.sin(alpha) / max(ld, 1e-6) steer = math.atan(wheelbase * curvature) return max(-max_steer, min(max_steer, steer))PID 用在纵向速度控制上依然是性价比最高的选择。整定顺序我推荐“先 P 后 I 再 D,最后加抗饱和”:先把 P 加到有一点超调但不振荡,再加 I 消除稳态误差,最后用 D 压超调。积分饱和是低速蠕行场景的头号杀手——车堵在原地,误差一直存在,积分项越积越大,一旦前车走了,车会猛地窜出去。解决办法是积分限幅加上“误差小于阈值才积分”的门控。
MPC 适合执行器冗余、需要处理约束的场景,比如同时约束转向角速率和加速度。它的代价是算力和参数调试成本,一个 20 步、状态维数 6 的线性 MPC,在嵌入式平台上跑 10 毫秒一帧是可以做到的,但前提是把 QP 求解器的规模压下来。不建议新手一上来就用 MPC,PID 调不明白的人,MPC 只会调得更乱,因为 MPC 的权重矩阵更难解释。
| 控制器 | 适用车速 | 关键参数 | 主要问题 |
|---|---|---|---|
| 纯跟踪 | 0~30 km/h | 前视距离系数 k、最小前视距离 | 高速下跟踪滞后、弯道切内 |
| 后轮反馈 | 0~20 km/h | 反馈增益 | 倒车和低速抖 |
| PID(纵向) | 全速域 | Kp、Ki、Kd、积分限幅 | 积分饱和、执行器延迟补偿 |
| MPC | 全速域 | 预测步长、Q/R 权重 | 算力、参数难解释 |
4. 仿真、数据闭环与车载部署:把“实验室能跑”变成“车上能跑”
前两部分解决“算法对不对”,这一部分解决“工程能不能落地”。我见过太多在回放数据上表现完美的模块,一上闭环仿真就崩,一上车就抖。原因几乎都在这三块。
4.1 仿真要分三层建,别指望一层打天下
第一层是回放仿真,也叫开环回放。把录制的传感器数据按时间戳重放给算法,算法输出的结果只记录不反馈。这层成本最低、复现性最好,适合做回归测试。它的致命缺陷是“不会因为你算错而改变世界”——你规划撞墙了,回放里墙还在那儿继续走,所以它测不出连锁反应。
第二层是闭环仿真。算法输出的控制量会驱动虚拟车辆,虚拟车辆的位置变化又会影响传感器看到的场景。这层能测出交互问题,比如加塞应对、路口博弈。成本在于场景建模和物理引擎的保真度,建得太真跑不动,建得太假结论没用。
第三层是故障注入仿真。人为制造传感器丢帧、时间戳跳变、定位跳变、通信延迟,看系统会不会崩。这一层最不受重视,但收益最高。我做过一组对比:同一套决策规划代码,在正常场景下通过率 92%,注入 200 毫秒通信延迟后掉到 61%,注入定位横向跳变 0.5 米后掉到 43%。这些数字不上车是绝对看不到的。
| 仿真层级 | 能发现的问题 | 发现不了的问题 | 单场景耗时(参考) |
|---|---|---|---|
| 开环回放 | 单模块精度、时延、边界条件 | 交互博弈、连锁失效 | 秒级 |
| 闭环仿真 | 交互、轨迹可行性、控制稳定性 | 真实传感器噪声分布 | 分钟级 |
| 故障注入 | 鲁棒性、降级策略、超时处理 | 真实硬件老化 | 分钟级 |
场景库的建设我建议用覆盖矩阵来管,而不是攒数量。矩阵的行是场景类型(跟车、变道、路口、环岛、窄路、施工、夜间、雨天),列是难度等级(简单、一般、困难、极端)。每个格子至少要有 N 个场景,缺格子的地方就是测试盲区。我在合集里给的建议是先把行铺满,再逐个格子加深,因为盲区的风险远大于难度不足的风险。
4.2 车载部署的资源账,得一笔一笔算
车载部署最常见的误判是把“标称算力”当成“可用算力”。一块标称 100 TOPS 的加速卡,实际能稳定跑出来的往往是标称值的一半到七成,因为内存带宽、功耗墙、散热都会限制它。合集里我建议按“实测吞吐”而不是“标称算力”做预算,并且留 30% 余量给峰值场景。
时延预算是另一笔必须算清楚的账。以 10 Hz 的感知周期为例,100 毫秒的预算要这样分:
| 环节 | 典型耗时 | 说明 |
|---|---|---|
| 曝光与读出 | 10~20 ms | 与分辨率、快门类型强相关 |
| 传输与拷贝 | 3~10 ms | 零拷贝能省掉大半 |
| 预处理 | 5~10 ms | 缩放、去畸变、归一化 |
| 模型推理 | 20~40 ms | 与输入分辨率强相关 |
| 后处理(NMS 等) | 5~15 ms | 目标多时容易失控 |
| 结果发布与同步 | 2~5 ms | 跨进程通信开销 |
把这张表填满,你会发现余量其实很少。所以我在合集里给了一条很实用的规则:任何一个环节的耗时波动超过均值的 30%,就必须查。波动往往意味着内存分配、线程调度或者缓存未命中出了问题,这类问题在负载低的时候看不出来,一上量产车就集中爆发。
内存这块也要单独说。嵌入式平台的内存分配最怕两件事:运行中动态申请和碎片化。我的做法是在初始化阶段把大块内存一次性分配好,运行期只做复用,日志和缓存全部走预分配的环形缓冲。功耗和散热更不能忽略——很多车规平台的降频保护触发得比想象中早,夏天跑半小时后算力掉两成是常事,这直接改变了你的时延预算。
注意:部署阶段的性能问题,八成不是算法太慢,而是数据搬运太多。先看拷贝次数,再看算子。
5. 常见问题与排查技巧实录
这一章是合集里我自己回看频率最高的部分,因为它是纯经验,不含任何理论。以下都是我或者合作的同学真实踩过、并且记录下来反复验证过的坑,实体信息全部做了泛化处理。
5.1 一份能救急的问题速查表
排查的第一原则是缩小范围,而不是猜原因。下面这张表是按“现象”组织的,不是按“模块”组织的,因为现场你只知道现象。
| 现象 | 第一怀疑对象 | 排查动作 | 常见结论 |
|---|---|---|---|
| 车在原地小幅来回摆动 | 控制死区或前视距离过小 | 打印转向指令与反馈 | 前视距离低于 3 米 |
| 跟车忽快忽慢 | 纵向 PID 增益过高或时距参数抖动 | 记录速度误差曲线 | Kp 偏大、缺低通 |
| 定位偶尔跳变十几厘米 | 时间同步或地图匹配退化 | 比对惯导原始输出 | 时间戳补偿没做 |
| 夜间误检突然增多 | 曝光策略或训练数据分布 | 分时段统计误检率 | 长尾场景欠采样 |
| 规划轨迹“贴边” | 障碍物膨胀半径过小 | 检查膨胀参数 | 膨胀值按静态设置未随速调整 |
| 仿真通过、实车失败 | 传感器噪声模型失真 | 用实车数据回流验证 | 仿真噪声过于理想 |
| 长时间运行后算力下降 | 散热或内存泄漏 | 监控频率与内存曲线 | 降频保护触发 |
举一个我印象最深的例子。某次测试发现车辆在通过一段缓弯时,方向盘会出现大约 2 赫兹的轻微抖动,幅度不大但很规律。第一反应是控制器增益问题,把 Kp 从 1.2 降到 0.8,抖动幅度确实小了,但过弯跟踪误差变大,弯道内侧会压线。继续查,打印了从感知到控制整条链路的时间戳,发现规划模块输出的轨迹时间戳和控制模块的消费时间戳之间存在 40 到 90 毫秒的不规则抖动,控制模块用了“最近一帧轨迹”但没有做时间对齐,导致同一个轨迹点在两次控制周期里被算出不同的期望曲率。最后是在控制入口加了轨迹的时间戳插值,抖动直接消失,增益也调回了 1.2。
这个案例给我的教训是:先查数据的时序,再查算法的参数。参数是果,时序往往是因。
5.2 合集维护与实操避坑的几条硬经验
第一条,任何参数都要带单位和生效范围。合集里我强制要求写成“前视距离系数 k = 1.0,适用车速 0~30 km/h,低速段建议 1.2”。只写一个数字是最坑人的,读者抄过去发现不灵,还以为自己环境有问题。
第二条,实验记录必须包含失败的那一半。我现在的记录模板固定五栏:假设、改动、结果、反例、结论。反例那一栏最重要——它逼着你去想“这个改动在什么情况下会变差”。大部分参数调整都不是单调变好的,只是恰好在你测的场景里变好了。
第三条,别在同一个变量上同时改两处。这是新手最常犯的错,一次改三个参数,效果变好了也不知道是哪个起作用,效果变差了也不知道是哪个拖后腿。每次只动一个,跑够样本量再动下一个。样本量这块我也吃过亏,早期拿三五个场景就下结论,后来发现同一套参数在不同场景下方差极大,至少要覆盖同一场景族的十几个变体才敢说有效。
第四条,降级策略要比主策略更早写完。传感器丢帧怎么办、定位多久没更新就切安全模式、通信超时阈值是多少,这些必须在功能开发的同时就把逻辑写清楚。我见过很多项目,主流程做得非常漂亮,一到降级就靠“实在不行就急刹”,这在低速无所谓,在高速上是灾难。合理的做法是分级降级:先降精度(比如从多传感器融合退到单传感器),再降功能(比如自动变道关掉),最后才是最小风险停车。
第五条,留一条“能回到昨天”的路。配置、标定、模型、参数,全部要能一键回滚,并且回滚之后能跑通。这条听着像运维的事,但它决定了你敢不敢改东西。我在合集里见过太多“改完之后出问题,但是回不去了”的惨案,最后只能靠重新烧录,一折腾就是半天。
最后分享一个我最近才养成的小习惯:给每条结论标上“置信度”和“最后验证日期”。置信度分三档——验证过多次、只验证过一次、纯推测。合集做到二十几期,内容里必然混着这三类,标出来之后,读者自己就能判断该不该信。这个习惯看着很朴素,但它把一份合集从“资料堆”变成了“有信誉的笔记”,我觉得这是第 24 期最值得做的事。