
1. 从一次诡异的横向偏移说起StBoundsDecider到底在管什么如果你在Apollo 6.0的规划模块里调过车大概率遇到过这种场景车辆在直道上跑得好好的一进弯道或者旁边有车压线方向盘就开始小幅画龙横向轨迹像喝多了似的左右微调。你翻遍LateralController的参数调来调去效果都不理想最后定位到问题出在规划输出的边界上——具体说就是StBoundsDecider这个任务给出的横向可行区间不合理。StBoundsDecider是Apollo规划器PublicRoadPlanner里一个不起眼但极其关键的任务。它挂在LANE_FOLLOW场景的ST_BOUNDS_DECIDER阶段核心职责是基于参考线和静态/动态障碍物为后续的速度规划器SpeedDecider、DP速度优化生成一条随时间变化的横向边界走廊。注意它输出的是STBoundary也就是时间-横向位置空间里的约束而不是直接给轨迹。速度规划器会在这个走廊里找一条既安全又舒适的速度曲线。很多人第一次看这个模块会懵为什么横向边界要用ST图来表达直接给个左右车道线不就行了这里的关键在于动态障碍物的横向侵入是随时间变化的。比如旁边车道有辆车正在往你这边并线它对你的横向挤压是渐进的不是一瞬间占满。StBoundsDecider要做的就是把这个渐进挤压过程量化成一条随时间演化的横向边界让速度规划器知道在t时刻我的横向活动范围被压缩到了多少。这篇文章我会把Apollo 6.0里StBoundsDecider的完整流程拆开讲透包括它的输入输出、核心算法逻辑、代码里几个容易看漏的细节以及我在实际调试中踩过的坑。适合正在做自动驾驶规划模块开发、需要深入理解Apollo规划架构的工程师也适合想搞明白边界决策这个中间层到底在干什么的读者。代码基于Apollo 6.0的modules/planning/tasks/deciders/st_bounds_decider/目录我会尽量把关键函数和数据结构对应上。2. StBoundsDecider在规划流水线里的位置与数据流2.1 它上游吃什么、下游吐什么先理清楚数据流不然看代码容易迷路。StBoundsDecider的输入主要来自三块参考线信息ReferenceLineInfo提供道路的左右边界、车道宽度、以及参考线上每个点的横向范围。这是静态边界的基础。障碍物列表Obstacles包括静态障碍物和动态障碍物每个障碍物有预测轨迹Trajectory包含未来一段时间的位置、速度、加速度。规划起始状态PlanningStartPoint当前车辆的位置、速度、朝向用来确定ST图的起点。输出是一个StBoundsDecider内部维护的std::vectorSTBoundary最终会塞进ReferenceLineInfo的st_graph_data里供下游的SpeedDecider和DpStSpeedOptimizer使用。这里有个容易忽略的点StBoundsDecider输出的边界是在Frenet坐标系下的横向边界也就是l方向横向偏移的上下限随时间t变化。它不直接管纵向s纵向的决策是SpeedDecider的事。两者配合才构成完整的ST空间约束。2.2 为什么需要独立的StBoundsDecider而不是直接用车道的边界这是我在面试新人时最爱问的问题。答案藏在动态两个字里。车道线给出的横向边界是静态的、几何的但实际行驶中横向可行空间会被动态障碍物实时压缩。举个例子你以60km/h行驶右前方一辆车以50km/h同向行驶且略微压线它对你的横向挤压在ST图上表现为一条斜线——随着时间推移它离你越来越近你的右边界不断左移。如果只用车道边界速度规划器会认为右侧一直有足够空间可能规划出一条贴着右车道线行驶的速度曲线结果就是不断逼近那辆车最后触发急刹或横向避让。StBoundsDecider的价值就在于提前把这种动态压缩量化出来让速度规划器在时间维度上就知道再过2秒右侧就没空间了从而提前减速或调整。2.3 任务在LANE_FOLLOW场景中的执行顺序在LANE_FOLLOW场景的Stage配置里ST_BOUNDS_DECIDER排在LANE_FOLLOW_DECIDER之后、SPEED_DECIDER之前。这个顺序不是随便定的LANE_FOLLOW_DECIDER先确定当前是跟车、超车还是保持车道输出横向决策意图。ST_BOUNDS_DECIDER根据这个意图和障碍物生成横向边界走廊。SPEED_DECIDER在走廊内做纵向速度决策。DP_ST_SPEED_OPTIMIZER做最终的ST图速度优化。所以StBoundsDecider是承上启下的角色它把横向决策意图翻译成可量化的边界约束供纵向优化使用。理解这个定位后面看代码就不会觉得它在多此一举。3. 核心算法拆解从障碍物到ST边界的三步走3.1 第一步构建初始的ST边界框架代码入口在StBoundsDecider::Process()它调用了InitSTBoundsDecider()。这一步做的是初始化工作核心是确定ST图的时间范围和横向范围。时间范围由FLAGS_st_bounds_decider_time_horizon控制默认是7秒左右。为什么是7秒这是Apollo在高速场景下的经验值7秒足够覆盖大多数跟车和并线决策的预判窗口再长预测精度下降再短来不及做平滑减速。横向范围则来自参考线的GetLaneWidth()和当前车辆的横向位置。初始化时会创建一个STBounds对象内部用std::vectorstd::pairdouble, double存储每个时间采样点的横向上下限。采样间隔默认0.1秒7秒就是70个点。这个密度是为了保证边界足够平滑避免速度规划器在离散点上产生抖动。注意时间采样间隔不要随意调大。我试过改成0.2秒结果在障碍物快速逼近的场景下边界出现阶梯状跳变速度曲线跟着抖。0.1秒是经过验证的平衡点。3.2 第二步静态障碍物与车道边界的融合静态障碍物比如路边停的车、锥桶对横向边界的约束是硬的——它们不随时间移动所以在ST图上表现为一条竖直的线在某个时间点之后横向范围被永久压缩。代码里处理静态障碍物的逻辑在GetStaticObstacleSTBoundary()。它会遍历所有静态障碍物对每个障碍物计算它在参考线横向上的投影范围然后更新STBounds的上下限。这里有个细节不是简单取交集而是要考虑障碍物的膨胀buffer。Apollo默认给静态障碍物加0.3米的横向buffer防止规划出的轨迹贴着障碍物边缘走。车道边界则来自参考线的GetRoadLeftBound()和GetRoadRightBound()它们本身就是横向的硬约束。静态障碍物的边界和车道边界取交集得到静态可行域。3.3 第三步动态障碍物的ST边界生成这是整个模块最复杂的部分代码在GetDynamicObstacleSTBoundary()。动态障碍物的处理逻辑可以概括为筛选相关障碍物不是所有障碍物都影响横向边界。只有那些在预测时间内可能进入自车横向范围的障碍物才需要处理。筛选条件是障碍物的横向距离小于某个阈值默认是车道宽度的一半加buffer。计算障碍物的横向侵入曲线对每个相关障碍物根据它的预测轨迹计算它在每个时间点占据的横向范围。这里要用到障碍物的预测轨迹和自车参考线的Frenet转换。更新STBounds把障碍物的横向侵入范围从当前可行域中挖掉。如果障碍物从右侧侵入就抬高下界从左侧侵入就压低上界。这里的关键难点是障碍物预测轨迹的不确定性。Apollo 6.0的预测模块会给出多条预测轨迹及概率StBoundsDecider默认取概率最高的那条但会加一个额外的安全buffer。我在实际项目里试过用多轨迹加权效果提升有限但计算量翻倍后来还是回到单轨迹加buffer的方案。3.4 边界平滑与合法性检查生成完原始边界后代码会做两件事平滑和检查。平滑用的是滑动平均滤波窗口大小3个采样点。目的是消除障碍物预测轨迹的抖动导致的边界毛刺。但平滑有个副作用可能把一些尖锐但必要的约束磨平。比如障碍物突然切入的场景平滑后边界会提前收缩导致自车过早减速。Apollo的处理方式是只对边界的变化率做限制而不是直接平滑位置这样既保证了平滑性又不会丢失关键约束。合法性检查在IsValidSTBounds()里主要检查三点上下界不能交叉否则可行域为空、边界变化率不能超过车辆最大横向加速度对应的值、边界不能超出道路物理边界。如果检查失败会触发一个fallback退回到只用车道边界的保守方案。4. 代码级细节几个容易看漏但很关键的地方4.1 STBounds的数据结构与更新逻辑STBounds类定义在st_bounds_decider.h里核心成员是std::vectorstd::pairdouble, double st_bounds_; // (lower, upper) per time step std::vectordouble time_steps_;更新逻辑在UpdateSTBounds()里它接收一个障碍物的横向范围和时间范围然后逐时间点更新。注意它是原地更新不是重新计算所以障碍物的处理顺序会影响最终结果。Apollo的做法是先处理静态障碍物再按障碍物距离自车的纵向距离从近到远处理动态障碍物。这个顺序保证了近处障碍物的约束优先符合先保证不撞近的的直觉。4.2 障碍物横向范围的Frenet转换动态障碍物的位置是在笛卡尔坐标系下的要转换到参考线的Frenet坐标系才能和STBounds比较。转换函数是cartesian_to_frenet()在modules/common/math/里。这里有个坑转换的精度依赖于参考线的采样密度。如果参考线采样太稀疏比如1米一个点转换误差可能达到0.1米以上导致边界不准。Apollo默认参考线采样是0.5米实测误差在0.05米以内可以接受。4.3 时间对齐问题障碍物的预测轨迹时间戳和STBounds的时间戳可能不对齐。代码里用线性插值做对齐插值函数在STBounds::GetBoundaryAtTime()。插值时要注意如果障碍物预测轨迹的时间范围比STBounds短超出部分按障碍物消失处理即不施加约束。这个处理在大多数场景下没问题但如果障碍物预测突然中断比如预测模块超时会导致边界突然放宽速度规划器可能做出激进决策。Apollo的缓解措施是给预测中断的障碍物加一个虚拟延续用最后已知速度外推一小段时间。4.4 参数配置与调参经验StBoundsDecider相关的参数在modules/planning/conf/planning.conf里常用的几个参数名默认值作用调参建议st_bounds_decider_time_horizon7.0ST图时间范围高速可加到8城区保持7st_bounds_decider_time_resolution0.1时间采样间隔不建议改static_obstacle_buffer0.3静态障碍物横向buffer窄路可减到0.2dynamic_obstacle_buffer0.5动态障碍物横向buffer保守场景可加到0.8max_lateral_acceleration2.0边界变化率上限舒适性优先可减到1.5我调过最坑的一个参数是dynamic_obstacle_buffer。默认0.5米在大多数场景够用但在国内这种电动车乱窜的路况下0.5米经常不够导致边界收缩太慢速度规划器反应不及。后来加到0.8米急刹次数明显减少但代价是通行效率下降容易被加塞。最后折中到0.6米配合预测模块的激进程度调整才找到平衡。5. 实测中的典型问题与排查链路5.1 问题一弯道边界抖动导致方向盘画龙现象车辆在弯道行驶时横向轨迹出现周期性小幅振荡频率大概2-3Hz。排查过程先看StBoundsDecider输出的边界日志发现边界在弯道处有高频抖动。进一步看障碍物列表发现是路边静态障碍物护栏的投影在弯道处因为参考线曲率变化横向范围计算出现跳变。根因静态障碍物的横向投影用的是参考线最近点弯道处最近点切换导致投影范围突变。修复在GetStaticObstacleSTBoundary()里加了一个投影平滑对连续时间点的投影范围做限幅变化率不超过0.1米/0.1秒。改完后抖动消失。5.2 问题二并线场景下边界收缩过晚现象旁边车道车辆并线切入时自车直到很近才开始减速乘坐体验差。排查过程看预测模块输出的并线车辆轨迹发现预测的并线开始时间比实际晚0.5秒左右。StBoundsDecider用的是预测轨迹所以边界收缩也跟着晚。根因预测模块的并线检测有延迟这是预测的固有难题。缓解在StBoundsDecider里加了一个并线意图预判如果障碍物的横向速度朝向自车且超过阈值0.3m/s即使预测轨迹还没显示并线也提前施加一个软约束。这个改动让边界提前0.3-0.5秒收缩体验改善明显。代码上是在GetDynamicObstacleSTBoundary()里加了一段横向速度检查。5.3 问题三STBounds为空导致规划失败现象极窄路段两车交会时规划模块报no valid ST bounds车辆急停。排查过程日志显示上下界交叉可行域为空。原因是两车交会时左侧障碍物和右侧障碍物的横向约束叠加加上buffer后没有剩余空间。根因buffer设置过于保守且没有考虑错时通过的可能性。修复在IsValidSTBounds()失败时不是直接fallback到车道边界而是尝试放宽时间维度允许在某个时间点之后才满足约束即把硬约束变成软约束时间窗。具体实现是给STBounds加一个relaxation_window在这个窗口内允许上下界短暂交叉由速度规划器决定是等待还是通过。这个改动比较复杂但解决了窄路交会的死锁问题。5.4 排查工具与方法论调试StBoundsDecider最有效的工具是Apollo自带的planning_visualizer它能把STBounds画在ST图上直观看到边界随时间的演化。我习惯在每次改动后导出ST图截图对比边界形状。另外--v3的日志级别会打印每个障碍物对边界的贡献定位是哪个障碍物导致边界异常非常有用。排查思路总结成一句话先看边界形状再看是哪个障碍物贡献的最后看这个障碍物的预测轨迹是否合理。大部分问题出在预测轨迹上StBoundsDecider本身只是忠实地把预测翻译成边界。6. 从StBoundsDecider延伸出的设计思考6.1 边界决策与轨迹决策的边界在哪里StBoundsDecider只给边界不给轨迹这个设计哲学值得琢磨。它的好处是解耦横向边界由障碍物决定纵向速度由优化器决定两者互不干扰。但坏处是信息损失边界只告诉优化器哪里不能去没告诉它哪里更好去。比如边界内有一块区域虽然可行但靠近障碍物优化器可能因为代价函数设计不当而选择贴边行驶。Apollo的解法是在速度优化器的代价函数里加横向舒适性项间接引导车辆走中间。但这是间接的不如直接在边界里加软约束来得直接。我在自己的项目里试过在STBounds里加一个推荐横向位置的字段优化器参考它做决策效果有提升但增加了耦合度后来还是回归了纯边界方案。6.2 预测不确定性如何传导到边界这是StBoundsDecider最脆弱的一环。预测模块给的是点估计StBoundsDecider把它当确定值用但实际预测有误差。误差传导到边界再传导到速度规划最后表现为车辆的保守或激进。Apollo 6.0的处理是加buffer但buffer是静态的不能随预测置信度动态调整。一个更优雅的方案是用预测的概率分布生成概率边界比如取90%分位数的横向范围作为边界这样置信度低的障碍物自动获得更大的buffer。我在实验里实现过简化版用预测轨迹的方差估计分位数效果不错但计算量增加约15%。如果你们的平台算力充裕值得一试。6.3 多障碍物交互时的边界组合当多个障碍物同时影响横向边界时StBoundsDecider是逐个处理的取所有约束的交集。这在大多数场景下没问题但有一种情况会出问题两个障碍物分别从左右挤压单独看每个都不致命但组合起来可行域为空。前面5.3节提到的窄路交会就是这种情况。更聪明的做法是考虑障碍物之间的相对关系如果两个障碍物是相向而行它们的挤压是暂时的可以错时通过如果是同向而行挤压是持续的必须找空间。Apollo 6.0没有做这个区分这是可以改进的点。我在自己的项目里加了一个简单的障碍物对分析对相向障碍物放宽时间约束效果立竿见影。6.4 参数自适应固定buffer的局限性固定buffer最大的问题是不能适应不同场景。高速上0.5米buffer足够城区窄路0.5米可能直接导致可行域为空。我试过用场景识别来动态调buffer高速用0.5城区用0.3停车场用0.2。实现方式是在StBoundsDecider初始化时根据ReferenceLineInfo的道路类型和当前速度查表。这个改动不大但减少了大量极端场景下的规划失败。最后分享一个我在实际调试中的体会StBoundsDecider的问题十有八九不是它自己的问题而是上游预测或下游优化的锅。它就像一个老实巴交的翻译把预测的话原样转达给优化如果翻译出来的边界不合理先别急着改它去看看预测说了什么。我踩过的坑里至少七成最后定位到预测模块的轨迹异常。所以调试这个模块先信边界再疑预测最后才动代码这个顺序能帮你省下大量时间。