如果你和我一样,在自动驾驶项目里啃过规划、控制的代码,那么对“预测”这个模块一定不会陌生。用一句话概括行为预测模块的职责:回答“周围这些车、行人、自行车,未来几秒会往哪走、各条路径有多大可能”。我最近把一套开源自动驾驶框架6.0版本的行为预测模块代码完整读了一遍,顺着一次预测任务从启动到输出的完整链路,把里面的输入输出、核心数据结构、预测器选择逻辑、场景交互建模统统过了一遍。这篇文章就是我的阅读笔记,适合两类人看:一类是刚接触自动驾驶代码、想快速理解预测模块全貌的新手;另一类是已经在做规划或者预测落地、想从代码层面印证自己判断的工程师。读完你至少能搞明白四件事:预测模块的输入输出和数据结构长什么样、代码是怎么给每个障碍物决定“用哪种预测器”的、为什么输出的是多条带概率的轨迹而不是一条、以及实车调试时轨迹跳变应该从哪几个方向查。
1. 先搞清楚:行为预测模块到底在解决什么问题
1.1 从一条典型的路口场景说起
假设你坐在测试车的副驾,车开到城市道路的路口准备左转。对向直行车流不断,右侧还有一个行人正在过斑马线。这时候,规划模块最想知道的是:那辆对向车是会减速让我先过,还是根本不打算让?那个行人会不会突然折返?这两件事直接决定自车是继续蠕动、还是完全刹停等待。但感知模块给不了这个答案——它只告诉你“前方40米有一辆车,速度35km/h”。真正把“感知到的当前状态”变成“未来几秒钟的可能行为”的,就是行为预测模块。
我刚接触这块代码的时候最大的误解是:预测就是“把当前速度外推一下”。等真读进去才发现,外推只是最基础的退路,真正支撑预测效果的是地图、历史轨迹和场景理解三个要素的叠加。后面第4节展开讲预测器时,你会看到这三种要素是如何在不同代码路径里各司其职的。
1.2 预测在整个自动驾驶链路中的位置
一条完整的自动驾驶数据链路大致是:感知(Perception)→ 预测(Prediction)→ 规划(Planning)→ 控制(Control)。感知负责回答“现在周围有什么”,预测负责回答“接下来会怎样”,规划在这两者的基础上决定“自车怎么走”,控制把规划结果变成方向盘和油门刹车指令。
需要注意的是,预测模块并不是独立大脑,它高度依赖上游和地图。上游感知的质量直接决定预测输入是否干净;地图的精度决定车道级预测有没有意义;定位和路由信息则帮助预测模块理解自车意图(比如自车下一段要左转),这在路口场景里尤为重要。读代码时如果只盯着预测算法本身而忽略这些输入,很多设计上的“为什么”都解释不通。
1.3 读代码前必须建立的两个基本概念
第一,预测输出的不是一条轨迹,而是一组轨迹,每条轨迹带一个概率。原因很简单:对方的意图本质上是随机的。代码里的数据结构基本都是“一个障碍物 + 多条候选轨迹 + 每条轨迹一个概率值”的组织方式。规划模块拿到这组轨迹后,会按概率加权去考虑风险,而不是只盯着最可能那条。
第二,预测的核心假设是“行为受约束”。车辆绝大多数时间受车道结构约束,所以代码里会出现“车道序列”这种把地图当成虚拟轨道的东西;行人和骑行者自由度更高,所以走的是另一套自由度更高的预测器。读代码的时候,只要看到某个文件在算“车道序列”,你就知道这是在给车辆行为找轨道;看到某个文件在做自由外推,那多半是在处理行人或未知障碍物。
2. 输入、输出与核心数据结构:先看清接口再谈算法
2.1 输入侧:感知、地图、定位与路由
先看输入。行为预测模块并不是自己感知世界,它消费多个上游消息。我读代码时会先找模块的订阅列表,这比看任何文档都直观。在6.0这套代码里,预测模块的输入大致有四路:
| 输入源 | 关键信息 | 用途 |
|---|---|---|
| 感知模块 | 障碍物id、类型、位置、速度、加速度、朝向、包围盒、置信度 | 构建障碍物对象与历史 |
| 高精地图 | 车道中心线、车道边界、停止线、人行横道、路口范围等 | 生成车道序列、判定场景 |
| 定位模块 | 自车位置、姿态、速度 | 坐标转换与场景分析 |
| 路由/上游规划 | 自车未来行驶路径 | 理解自车意图,辅助交互判断 |
这四路输入里,感知的作用最直接——每个障碍物的id、类型、位置速度朝向,全部来自感知消息。地图数据则是通过地图服务接口按需查询的,比如“某障碍物附近200米有哪些车道”。定位和路由更多用在场景分析和自车意图理解上,在路口场景尤其重要。
2.2 输出侧:带概率的多条候选轨迹
输出侧就清晰很多了:预测模块向规划模块发布一个预测结果消息,里面是每个被预测障碍物的一组轨迹。每条轨迹由一串带时间戳的轨迹点组成,每个轨迹点包含位置、朝向、速度等字段,并且整条轨迹带一个概率值。这个概率不是随意填的,它来自评估器对轨迹代价的转换计算,后面第3.5节会详细讲。
另外,输出里通常还会区分轨迹类型,比如“沿当前车道保持”“向左变道”“向右变道”“停在停止线前”等。规划模块拿到后,会按轨迹类型做语义层面的判断,而不只是看几何点列。这是我读代码时才注意到的细节:预测不是一个纯几何问题,它同时还在输出“语义意图”。
2.3 几个绕不开的核心数据结构
读代码时最先要认识的是那几份消息定义和内部类。我用自己的话整理一份简化版:
// 轨迹点:预测轨迹的基本单元 message TrajectoryPoint { double timestamp = 1; // 相对时间 double x = 2; double y = 3; double theta = 4; // 朝向 double velocity = 5; double acceleration = 6; } // 一条预测轨迹:带概率 message PredictionTrajectory { double probability = 1; repeated TrajectoryPoint trajectory_point = 2; } // 一个障碍物的预测结果 message PredictionObstacle { int obstacle_id = 1; repeated PredictionTrajectory trajectories = 2; // 可能还有优先级、预测类型等字段 }这是输出侧的序列化消息。内部实现里,每个障碍物还有一个“历史缓冲”结构,通常是一个按时间排序的状态队列,存最近N个周期的位置、速度、加速度。为什么要存历史?因为车道变更意图、加加速度这些特征必须靠历史窗口才能算出来。
还有一个容易被忽略的结构是“时间对齐”。感知消息的每个障碍物状态都带时间戳,预测模块内部会统一使用一个相对时间基准,避免不同周期之间时间错乱。我见过不少预测轨迹跳动的问题,最后都查到了时间戳没有对齐头上。
2.4 数据流与模块间通信
在6.0这类组件化架构里,预测模块通常以组件(Component)的形式注册在框架中,订阅感知消息,处理完后发布预测消息。简化理解就是:框架每收到一帧感知消息,就触发一次预测任务。
我建议读代码时先画一张极简的数据流图:感知消息进来 → 组件回调 → 容器更新 → 场景分析 → 逐个障碍物预测 → 结果组装 → 发布。后面第3节就沿着这条链路往下走。这张图不需要画得很精细,重要的是让你在深入细节前先有一个“骨架”,这样看代码时不会迷路。
3. 主流程代码走读:一次预测任务的生命周期
3.1 入口函数与组件初始化
以C++代码为例,预测组件的入口一般长这样(简化版):
class PredictionComponent : public Component<PerceptionObstacles> { public: bool Init() override { container_ = std::make_shared<ObstaclesContainer>(); scenario_ = std::make_shared<ScenarioAnalyzer>(); manager_ = std::make_shared<PredictorManager>(); // 从配置加载预测器列表、评估器列表 return true; } bool Proc(const PerceptionObstacles& perception_obstacles) override { // 1. 更新障碍物容器 container_->Update(perception_obstacles); // 2. 场景分析 Scenario scenario = scenario_->Analyze(container_->GetEgoState()); // 3. 对每个障碍物做预测 std::vector<PredictionObstacle> results; for (const auto& obstacle : container_->GetObstacles()) { if (NeedPredict(obstacle)) { PredictionObstacle result = manager_->Predict(obstacle, scenario); results.push_back(result); } } // 4. 组装并发布 PublishPredictionResults(results); return true; } };注意几个点。Init阶段会按配置注册可用的预测器,这意味着“用哪套算法”完全可以通过配置切换,代码层面不用改。Proc是每帧感知都会回调一次,整个预测流水线必须控制在这个周期内完成(通常是100ms级别的预算)。我第一次看这段代码时最大的困惑是“为什么入口这么简单”,后来才意识到,复杂度全都被下沉到了容器、场景分析器和预测器管理器里。
3.2 障碍物容器:如何“记住”历史
ObstaclesContainer(障碍物容器)是本次周期里所有障碍物的总账本。它内部维护一个以障碍物id为键的映射表,每个值就是一个障碍物对象。Update的具体过程通常是三件事:
- 对消息里的每个障碍物,按id查找容器里是否已经存在;存在则把新状态追加到它的历史队列末尾,不存在则创建新对象并初始化历史;
- 对容器里那些“这次感知没出现”的障碍物,先不急着删,而是记一个“失联计数”,连续若干个周期都没出现才删除;
- 对历史队列做截断,只保留最近N个周期的状态。
这个设计直接影响预测质量。如果感知模块的障碍物id不稳定,同一个物体隔几帧换个id,容器就会把它当成两个不同物体,历史的连续性就断了。这也是实车调试中“轨迹突然乱跳”最常见的根因之一,第6节会细说。
3.3 场景分析器:判断当前环境
场景分析解决的是“上下文”问题。同样是前方有车,高速巡航和市区路口,预测逻辑完全不同。代码里ScenarioAnalyzer的输入是自车状态和地图信息,输出是一个场景类型,比如路口(Junction)、车道变更(LaneChange)、正常行驶(Cruise)等。
实现上,场景分析器会有一些阈值和规则,比如:自车距路口中心点小于某个距离且路由路径包含转弯,判定为路口场景;自车前方某条相邻车道被慢车占据,判定为潜在变道场景。读代码时不用纠结每个阈值的绝对数值,重点理解“场景是预测器选择的一级开关”。场景分析通常在障碍物循环之外先做一次,因为它是全局上下文,不需要每个障碍物单独算。
3.4 预测器选择逻辑:每个障碍物用哪种预测器
这是预测模块里最核心的一段逻辑。在6.0风格的代码里,PredictorManager会根据障碍物类型、所在场景、是否有可用车道信息,决定调用哪个预测器。我用表格整理一下典型的对应关系:
| 障碍物情况 | 典型预测器 | 输出特点 |
|---|---|---|
| 车道上有车道序列的车辆 | 车道序列预测器 | 沿车道给出多条轨迹,带变道意图 |
| 路口内的车辆 | 路口专用预测器(规则式或模型式) | 多条可能行驶方向,概率差异明显 |
| 行人/骑行者/未知类型 | 自由运动/外推预测器 | 短时有效,方向发散 |
| 静止障碍物(速度接近0且无明显意图) | 停止预测器 | 基本不动,轨迹近似一个点 |
| 有历史速度但没车道信息的物体 | 匀速/匀加速度外推 | 简单直接,适合短预测时域 |
选择逻辑写出来通常是连续的if/else或者一个查找表。比如:先判断障碍物是否静止;再判断是否有有效车道;再判断当前场景是不是路口。每一层分支都会缩小预测器候选集。
这里有个很关键的工程细节:预测器是注册制的。Manager里维护一个名称到实例的映射表,配置里写“用哪个预测器处理哪类障碍物”,代码里通过名字去实例化。这样新增一种预测算法,只需要实现一个接口并注册,完全不动主流程。这个设计模式,也是这套代码值得借鉴的地方。
3.5 评估器与轨迹筛选
预测器负责“生成候选轨迹”,评估器负责“给候选轨迹打分”。一次预测可能生成几条到几十条候选轨迹,不可能全发下去,评估器要挑出最合理的3~5条,并算出概率。
以代价评估器为例,一条候选轨迹的代价通常由几部分叠加:
- 运动学代价:加速度过大、急转弯等惩罚;
- 地图代价:偏离车道中心线、跨越实线等惩罚;
- 意图代价:与障碍物历史运动方向不一致的惩罚;
- 交互代价:与周围障碍物存在碰撞风险时惩罚。
代价算完后,通过一个softmax变成概率:p_i = exp(-cost_i) / Σ_j exp(-cost_j)。这样所有轨迹的概率加起来等于1,最合理的轨迹概率最高。代码里通常还会设一个保底措施:如果所有候选轨迹的代价都特别大,就保留一条“最不坏”的轨迹,避免输出空集合。
为什么要硬性要求概率和为1?因为规划模块要用概率做风险加权评估,如果概率不规范,加权就没意义。这是读代码时很容易忽略、但实际非常重要的约束。
3.6 结果回写与发布
最后一步是把内部结构转回消息格式,按障碍物id组织成一条条PredictionObstacle,然后发布出去。发布前通常还会做一次过滤:距离自车太远的障碍物、完全静止且不碍事的障碍物,可能被跳过不发布,减少下游计算压力。
发布后,预测模块本次任务就结束了。真正难的地方在于把前面几节的内容组合起来跑得稳、跑得快,这也是后面两节要展开的内容。
4. 预测器内部:规则、外推与学习式三种思路的代码体现
4.1 车道序列预测器:把地图当“轨道”
先说最常见也最规则化的车道序列预测器。它做的事情可以拆成三步:第一步,从障碍物当前所在车道出发,在地图上找出接下来可选的车道序列——比如“继续在当前车道”“向左变到相邻车道再继续”“向右变道”等;第二步,对每一条车道序列,把障碍物从当前位置投影到车道上,顺着车道中心线生成一串轨迹点;第三步,结合障碍物的历史速度意图和周围车流情况,给各条轨迹分配概率。
这里最关键的细节是“车道序列”的采样方式。代码里通常有一个GetLaneSequences之类的函数,按纵向推进距离和横向变道次数枚举组合,再过滤掉那些包含实线变道或逆行等非法组合的序列。读到这里你就会明白,为什么预测轨迹总是“贴地图”:因为地图本来就是轨迹的生成器。
生成轨迹时,纵向速度剖面一般直接来自障碍物历史速度的平滑结果,横向则用一条平滑的S形曲线完成变道过渡。这个S形曲线是代码里调参的重灾区——横向切入太快,轨迹像“甩尾”;太慢,轨迹和障碍物实际行为不符。
4.2 运动外推预测器:匀速/匀加速度的工程实现
没有车道、没有地图信息怎么办?外推预测器就是那个“兜底”方案。实现思路很直白:取障碍物最近几帧的速度和加速度,按运动学公式直接外推未来轨迹:
def extrapolate(state, horizon, dt): traj = [] t = 0.0 x, y, vx, vy, ax, ay = state while t <= horizon: x += vx * dt + 0.5 * ax * dt * dt y += vy * dt + 0.5 * ay * dt * dt vx += ax * dt vy += ay * dt traj.append((t, x, y, math.atan2(vy, vx), math.hypot(vx, vy))) t += dt return traj代码本身只有十几行,难点全在“输入噪声”处理上。感知给的速度加速度抖动很大,如果直接拿原始值算,轨迹会像醉汉走路。工程上常见的做法是:先对加速度做低通滤波,再对外推速度做饱和处理(限速在合理范围),最后把时间步长dt取0.1秒左右,保证轨迹点密度够下游用。
外推预测器在短时域内(比如2秒内)效果不错,但到第5、6秒时基本就是猜了。所以代码里一般会把它用于行人、未知障碍物,或者只作为其他预测器的回退方案。
4.3 学习式预测器:从MLP到更复杂的模型
6.0这套代码里,学习式预测器的典型形态是MLP(多层感知机)。拿路口场景举例:车辆开进路口后,可能的出口很多,规则式枚举车道序列又很难覆盖,于是代码用一个MLP来打分。输入是手工构造的特征向量——包括障碍物相对路口的距离、历史速度序列、可能的出口方向特征等;输出是各个候选出口方向的概率。
这类代码的工程难点集中在特征提取与对齐。模型训练时用的特征和线上推理时必须完全一致,差一个归一化参数都会导致概率分布崩掉。我在读代码时特别注意了“特征归一化”这段:均值、方差、裁剪阈值通常以配置形式固化在模型配置文件里,推理代码只是照着配置做变换。
如果你打算在自己的项目中接入模型预测,我建议先把手写特征和简单的MLP跑通,再考虑视觉语义地图这类更重的方案。因为模型越复杂,debug成本越高,而预测模块的产出又是规划模块直接消费的,稳定性永远优先。
4.4 三种思路的取舍:为什么代码里三种都有
读完预测器目录你会发现,平台没有只用一种算法,而是把规则式、外推式、学习式混在一起,各管一段。用表格可以看得很清楚:
| 思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 规则式(车道序列) | 可解释、轨迹贴合地图、无需训练 | 依赖地图质量,规则覆盖不全 | 结构化道路上的车辆 |
| 外推式 | 实现简单、算得快 | 长时预测不可靠 | 行人、未知物、无地图区域 |
| 学习式 | 能在复杂场景学到隐含规律 | 需要数据、训练与调优成本高 | 路口、密集交互场景 |
这其实也是整个预测模块世界观的一种体现:不确定度越高的障碍物,越倾向于用保守简单的预测器;结构约束越强的场景,越倾向于用规则精细的预测器。
5. 场景与交互建模:代码里最难啃的部分
5.1 场景分析为何如此重要
同一个障碍物在不同场景里的可预测性完全不同。高速公路上大家基本沿车道走,预测只需要在“保持、左变道、右变道”里选;路口里车辆可能左转、右转、直行、掉头,预测输出天然发散;学校区域行人可能毫无预兆横穿,预测模块必须把这种不确定性放大。
代码里的体现就是场景类型会成为一组参数的总开关。读到配置文件的场景参数分组时,你会发现路口场景的预测时域、候选轨迹数量、概率截断阈值都和巡航场景不一样。这说明场景分析不只是“选预测器”,还影响整个参数面。
5.2 交互建模的实现方式
交互是预测里最微妙的部分。一辆准备变道的车,它的行为会受到旁边车道快车的影响;一个人行横道上的行人,会因为自车靠近而改变轨迹。规则式代码里,交互通常体现在评估器的交互代价项:生成候选轨迹后,逐点检查该轨迹与周围其他障碍物的未来轨迹是否存在冲突,冲突越严重,代价越高。
简化代码大概是这个样子:
double InteractionCost(const Trajectory& traj, const std::vector<Obstacle>& neighbors) { double cost = 0.0; for (const auto& p : traj.points) { for (const auto& other : neighbors) { double ttc = ComputeTTC(p, other); // 碰撞时间 if (ttc < 3.0) { cost += kInteractionWeight / (ttc + kEpsilon); } } } return cost; }注意,这套规则式交互非常朴素,它只能表达“靠近就惩罚”,表达不了“对方看到我减速了他会加速”这种博弈。真正复杂的交互要交给学习式模型。但规则式交互在6.0里至少保证了:预测轨迹不会和自车产生明显碰撞冲突,这是规划敢不敢用的底线。
5.3 语义地图转换与特征抽取的trick
预测代码里有一类工作叫“语义地图转换”,把高精地图转换成更适合计算的形式。在6.0这类框架里有两种做法:一种是结构化查询——把地图车道、停止线等抽成一个个几何对象,按需查询;另一种是栅格化——把地图画成一张或多张特征图,直接当图像喂给模型。
读结构化查询的代码时,重点看它的坐标系约定。地图查询结果通常是世界坐标系下的折线,而预测模块内部既有世界坐标也有障碍物局部坐标,混用就会出问题。栅格化方案则要注意栅格分辨率、覆盖范围和更新频率——语义图不用每帧重画,可以缓存,只在自车移动超过一定距离后更新,这是很常见的性能优化手段。
我自己读代码的经验是:遇到地图相关逻辑,先在纸上把坐标变换写一遍——从地图数据到障碍物坐标系,每一步是平移还是旋转,代码里对应的函数就是哪几个。把这个理顺,语义地图这块基本就不会迷路。
6. 实测踩坑与调优心得:轨迹跳变、时间戳与其他
6.1 排查预测轨迹跳变问题
先说一个我实际遇到过的案例。测试车里,前方车辆明明在直行,预测模块输出的轨迹却每隔几帧在“直行”和“向左变道”之间来回跳,规划模块跟着频繁调整自车速度,坐在车里感觉一拱一拱的。
我的排查链路是这样的:第一步,看可视化面板,确认跳变的是轨迹横向上,而不是纵向速度;第二步,查障碍物id是否稳定——发现同一辆车在感知层面偶尔换个id,导致容器里历史断裂;第三步,查意图过滤器参数——历史窗口太短,横向速度稍有噪声就被判定为有变道意图。最后是两条线一起治:感知侧尽量把id融合做稳,预测侧把历史窗口加长、把变道置信度阈值提高。改完再测,跳变现象明显缓解。
这个案例很典型:预测结果异常,根源经常不在预测模块本身。调试时永远先把输入链路查彻底,再回头怀疑算法。
6.2 时间戳与坐标系:最容易翻车的地方
预测代码里有两个“幽灵级”问题:时间戳和坐标系。时间戳上,预测模块必须保证每条轨迹的时间基准一致,一般用“当前周期起始时刻+偏移”。如果有障碍物的时间戳是上一帧的,算出来的速度就偏了,轨迹就会整体滞后。
坐标系上,最常见的是世界坐标系与自车坐标系的混用。比如地图查询默认返回世界坐标,而障碍物历史存在局部坐标里,直接相减会得到完全错误的方向。我的习惯是统一约法三章:内部状态一律用世界坐标系存储和计算,只有输出给某些下游模块时才转成自车坐标系;所有坐标转换收敛到少数几个函数,禁止在业务逻辑里到处手写sin/cos。
6.3 调参与性能优化
调参是预测工程绕不开的日常。下面是我常用的几组参数和它们的影响:
| 参数 | 常见范围 | 影响 |
|---|---|---|
| 预测时域 | 5~10秒 | 越长越发散,规划越保守 |
| 轨迹点间隔 | 0.1~0.5秒 | 越小下游越平滑,计算量越大 |
| 每障碍物输出轨迹数 | 3~8条 | 太多下游负担大,太少覆盖不全 |
| 历史窗口长度 | 5~20帧 | 太长响应慢,太短意图抖动 |
| 失联保留周期 | 2~10帧 | 越久越能容忍感知中断,但僵尸障碍物多 |
性能上,最大的开销是地图查询和候选轨迹代价评估。常用的三板斧:只对自车前方合理距离(比如80~120米)内且类型明确的障碍物做完整预测;把地图查询结果按障碍物所在区域缓存;用线程池把每个障碍物的预测并行化。这样整帧预测耗时能压缩到几十毫秒以内。
6.4 可视化调试建议
最后说说调试工具。大部分开源平台都自带了可视化面板,可以叠加显示障碍物历史轨迹、预测候选轨迹、概率值和场景边界。我调试预测时的固定动作是:开一帧实车数据回放,盯着三个东西看——历史轨迹是否连续、预测轨迹是否贴合地图、概率分布是否合理。
三个典型现象值得记住:如果所有轨迹都偏向同一个方向,多半是代价权重失衡;如果概率之和明显不等于1,检查评估器的softmax实现;如果轨迹穿过实线或隔离带,检查车道序列采样时是否做了非法序列过滤。
还可以在日志里把每个障碍物id、类型、选中的预测器名称、输出轨迹条数和最大概率打出来。真出问题的时候,这条日志链能帮你快速定位是不是预测器选择逻辑跑偏了。
最后再分享一个我个人的小习惯:不要把预测模块当成黑盒,也不要把它当成单纯的算法题。它其实是整个系统里“世界模型”最浓缩的部分——地图、感知、意图、交互全都压在这个模块里。我读这套代码最大的收获不是记住了哪个函数,而是学会了一种思考方式:任何预测输出都要先问一句,是哪条输入链路或哪个前置假设让它变成这样的。带着这个视角去读代码,你会发现每一行if/else背后都是对真实世界不确定性的妥协。希望这份阅读笔记能帮你少走一些弯路。