
做飞控的时候,地图对我来说就是一个DEM高程文件,几十MB的二进制,飞机飞起来往里查高度就行。转到自动驾驶后我发现,这行的地图完全是另一个物种——它不描述地形,它描述规则。
Apollo的高精地图不是给人看的导航图。它是给规划器看的规则书:哪里能走,哪里不能走,车道线是虚是实,限速多少,前方可变道还是只能直行。每一条车道都有自己的ID,每一条车道线都有自己的类型。这个粒度的地图,是Apollo做车道级路由的前提,也是整个规划栈的基石。
proto里的世界
Apollo的HD Map内部格式是protobuf。打开modules/map/proto/map.proto,能看到Map消息的顶层定义:
messageMap{
optional Header header = 1;
repeated Crosswalk crosswalk = 2;
repeated Junction junction = 3;
repeated Lane lane = 4;
repeated StopSign stop_sign = 5;
repeated Signal signal = 6;
repeated YieldSign yield = 7;
repeated Overlap overlap = 8;
repeated ClearArea clear_area = 9;
repeated SpeedBump speed_bump = 10;
repeated Road road = 11;
repeated ParkingSpace parking_space = 12;
repeated PNCJunction pnc_junction = 13;
repeated RSU rsu = 14;
...
}

Lane是这个体系的核心。一条Lane的proto定义里塞了二十多个字段:central_curve描述中心线,left_boundary和right_boundary描述边界,predecessor_id和successor_id串联前后车道,left_neighbor_forward_lane_id和right_neighbor_forward_lane_id标记同向邻居。还有一个speed_limit,单位m/s,规划器直接读来约束速度规划。
Lane的类型也做了枚举:CITY_DRIVING、BIKING、SIDEWALK、PARKING、SHOULDER。转向类型有NO_TURN、LEFT_TURN、RIGHT_TURN、U_TURN。这些字段不是装饰,Routing模块在构建拓扑图时会直接用它们计算边的代价。
Road是Lane的容器。一条Road包含若干RoadSection,每个RoadSection里是一组Lane的ID列表加上边界。Junction描述路口区域,内部通过Overlap关联车道、信号灯、人行横道等元素。Apollo在标准OpenDRIVE的基础上做了定制:用绝对坐标点序列替代方程描述形状,新增了Overlap机制描述元素间的空间重叠关系,还加了车道中心到道路边界的距离采样(left_road_sample/right_road_sample)。
这套定制让Apollo的地图比标准OpenDRIVE更适合自动驾驶的实时查询,但也意味着从标准OpenDRIVE转换到Apollo格式需要额外处理。我踩过一个坑:直接把Carla仿真器导出的OpenDRIVE塞进Apollo,Lane的predecessor/successor关系全丢了,Routing模块直接建不出拓扑图。
从文件到KD-Tree
地图加载的入口是HDMapUtil,一个单例。它读入proto文件,解析成HDMap对象,然后构建空间索引。

modules/map/的目录结构很清晰:proto/放定义,hdmap/放实现。HDMapImpl类负责所有查询接口:GetNearestLane、GetLaneById、GetJunctionById、GetForwardNearestSignalOnLane……每个查询背后都是KD-Tree在撑。
Apollo用的是AABoxKDTree2d,一个2D轴对齐包围盒的KD-Tree。构建车道KD-Tree时,每条Lane的central_curve被离散成LineSegment2d序列,每个线段生成一个LaneSegmentBox,包含车道ID和线段索引。构建参数很务实:max_leaf_size=16,max_leaf_dimension=5.0m。意思是叶节点最多16个对象,空间范围不超过5米。这个粒度保证了查询时的精度,5米对于车道级定位来说足够了。

范围查询的复杂度是O(logN + K),K是结果数。在实际使用中,感知模块调用GetROI拿周边车道,定位模块调用GetNearestLane做匹配,都是这个索引在响应。没有它,每次查询遍历所有Lane,几万条车道的地图直接卡死。
GetProjection方法值得一提。它把世界坐标点(x,y)投影到最近车道的Frenet坐标系(s,l)。先KD-Tree查最近线段,再投影计算,最后用accumulated_s数组累加得到全局s坐标。这个方法在规划模块里被疯狂调用——参考线匹配、障碍物投影都走这条路径。
拓扑路由:在车道图上走A*
Routing模块要做的事情听起来简单:给定起点终点,找一条路。但它不是在路网上找,是在车道图上找。

Apollo的Routing模块依赖一个预生成的拓扑地图文件routing_map.*,通过scripts/generate_routing_topo_graph.sh从HD Map生成。这个拓扑地图的结构定义在modules/routing/proto/topo_graph.proto里:
Node:一个Lane Segment,包含车道ID、道路ID、长度、中心线曲线、代价系数 Edge:两条Lane之间的连接,包含起止车道ID、切换代价、方向(FORWARD/LEFT/RIGHT)
TopoGraph类在初始化时读入这个文件,构建完整的节点和边。Navigator类持有TopoGraph实例,对外暴露SearchRoute接口。
搜索算法是A*。实现位于modules/routing/strategy/a_star_strategy.cc。核心数据结构很标准:open_set_用优先队列,closed_set_用unordered_set,g_score_存实际代价,came_from_存父节点用于回溯路径。启发函数HeuristicCost用的是节点间的欧氏距离。
有个细节值得说。A*在扩展节点时,会判断当前节点到终点的剩余距离是否大于FLAGS_min_length_for_lane_change。如果剩余距离够长,就考虑前方和左右所有出边(允许变道);如果太短,只走前方出边(禁止变道)。这个设计很合理:快到路口了还在变道,容易出事。
边的代价不是简单的距离。routing_config.pb.txt里定义了代价系数:限速低的Lane代价高,弯道Lane代价高,变道切换还有额外代价。这些权重调得好,路由结果就会倾向于走大路、少变道;调得差,可能规划出频繁变道的诡异路线。
RoutingRequest:起点终点与黑名单

RoutingRequest的proto定义:
messageRoutingRequest{
optional apollo.common.Header header = 1;
repeated LaneWaypoint waypoint = 2;
repeated LaneSegment blacklisted_lane = 3;
repeatedstring blacklisted_road = 4;
optionalbool broadcast = 5 [default = true];
optional apollo.hdmap.ParkingSpace parking_space = 6;
}
waypoint是路径点,repeated意味着支持多途经点。每个LaneWaypoint包含车道ID、s坐标和ENU位置。请求进来后,Navigator先把waypoint转换成TopoNode,然后在TopoGraph上跑A*。
blacklisted_lane和blacklisted_road是黑名单机制。有些Lane可能临时施工、数据缺失,需要动态屏蔽。BlackListRangeGenerator类从请求中提取黑名单,写入TopoRangeManager。搜索时,被屏蔽的节点和边会被跳过。SubTopoGraph类处理这个逻辑——它根据黑名单从完整TopoGraph中裁剪出子图,A*在子图上搜索。
RoutingResponse是三层嵌套结构:RoadSegment → Passage → LaneSegment。RoadSegment对应一条道路,Passage是一段不含变道的连续通路,LaneSegment是具体的车道段。Planning模块拿到这个结果后,会在每个LaneSegment的central_curve上生成参考线,再做轨迹规划。
车道级路由 vs 道路级路由

打开高德或百度地图搜路线,返回的是道路级路由:走哪条路,在哪个路口转弯。Apollo返回的是车道级路由:走哪条车道的哪一段,在哪个位置变道,变到哪条车道。
粒度差异带来的是精度差异。道路级路由告诉你"前方500米右转",车道级路由告诉你"前方300米从当前车道变道至右侧第二条车道,再行驶200米进入右转车道"。对于人类驾驶员,前者够了;对于自动驾驶规划器,后者是刚需——它需要在Frenet坐标系下做轨迹规划,必须知道自己在哪条车道、目标车道在哪。
但车道级的代价是维护成本。一条城市道路的Lane数量可能是Road数量的5到10倍,拓扑图的节点和边成倍增长。更致命的是时效性:修路改一条车道线,整条Lane的前后继关系可能都要重算,拓扑图必须重新生成。高德百度那种路网级数据,一个月更新一次够用;Apollo的车道级数据,一周更新一次都嫌慢。
这是第一个争论点:车道级路由的精度收益,是否值得为维护成本和实时性买单? 在封闭园区或高速场景,车道结构稳定、Lane数量可控,答案倾向于"是"。但到了城区复杂路口,车道线磨损、施工改道频繁,Lane拓扑随时失效,答案就没那么确定了。
重地图 vs 无图

Apollo是一个典型的重地图方案:定位依赖HD Map匹配,感知用HD Map做ROI过滤,预测用HD Map查车道关联,规划用HD Map做参考线,Routing用HD Map建拓扑图。地图是这个系统的脊椎骨。
但行业趋势在变。2024年以来,国内主流智驾方案纷纷宣布"去高精地图"。小鹏的XNet、华为的ADS 3.0、理想的端到端方案,都在用BEV+Transformer实时感知替代预制地图。特斯拉更极端,连SD Map都很少依赖,靠视觉实时理解道路结构。
理由很实在:高精地图采集成本每公里千元级,全国覆盖百亿投入;更新频率跟不上道路变化,一个月前画的施工区可能已经拆了;还有测绘资质的监管收紧,2025年自然资源部进一步强化了对高精地图数据的安全审校。
这是第二个争论点:Apollo的重地图路线是否还站得住? 答案取决于场景。Robotaxi在固定城市运营,地图可以集中维护、高频更新,重地图方案的可靠性优势依然明显。但面向量产乘用车的全场景智驾,地图覆盖和更新确实是硬伤。行业正在往轻地图+强感知的方向走,Apollo也在做调整——相对地图(Relative Map)模块就是一个折中方案,用实时感知结果补充预制地图的不足。
不过有一点经常被忽略:无图方案解决的通常是感知和定位层面的问题,但Routing层面,你仍然需要某种道路拓扑来规划路线。只不过这个拓扑可能从SD Map来,而不是从HD Map来。地图可以变轻,但拓扑不能消失。
本篇源码路径汇总:
地图proto定义: modules/map/proto/地图加载与查询: modules/map/hdmap/空间索引: modules/common/math/aaboxkdtree2d.h拓扑图定义: modules/routing/graph/A*搜索: modules/routing/strategy/a_star_strategy.cc导航器: modules/routing/core/navigator.h路由proto: modules/routing/proto/
下篇预告:Apollo的Chassis与CanBus——从物理总线到软件消息。
夜雨聆风