4、数据模型设计:高精地图数据模型(NDS、OpenDRIVE)、车道模型、路口模型、拓扑关系

聊到高精地图的数据模型,我脑子里第一个蹦出来的词就是「混乱」。

为什么这么说?因为市面上主流的格式太多了。NDS、OpenDRIVE、还有各家图商自己的私有格式。你想想看,一个行业连数据怎么存都没统一,这活儿能好干吗?

但话说回来,作为嵌入式工程师,我们没得选。车端芯片就那么点资源,存储、算力、实时性,哪个都是硬约束。所以今天这节,我就把我在项目中踩过的坑、总结的经验,掰开了揉碎了讲给你听。

4.1 两大主流标准:NDS 与 OpenDRIVE

先说说 NDS。NDS 全称是 Navigation Data Standard,它最早是给导航用的。但后来发现,导航那套数据模型太「糙」了——只有道路级别,没有车道级别。高精地图需要的是厘米级的精度,所以 NDS 后来出了个扩展版本,叫 NDS.Live。

我个人习惯把 NDS 理解成一个「数据库容器」。它不规定你存什么,只规定你怎么存。比如数据分块、版本管理、增量更新,这些都是 NDS 的强项。我在做量产项目时,用的就是 NDS 格式。原因很简单:它支持差分更新。你想想看,全国地图几十个 GB,每次 OTA 都全量下载?车机那点流量根本扛不住。

核心要点: NDS 的本质是一个 SQLite 数据库的封装。每个表、每个索引,都是预先定义好的。你只需要往里面填数据。

再来看 OpenDRIVE。这个标准是 ASAM 组织搞的,主要用在仿真和测试领域。它的核心是「道路网络描述」。说白了,就是告诉你这条路有几条车道、车道多宽、曲率多少、坡度多少。

OpenDRIVE 用 XML 格式存储。嗯,这里要注意:XML 解析在嵌入式平台上非常吃资源。我见过一个项目,直接把 OpenDRIVE 文件丢给车机解析,结果启动时间从 2 秒变成了 20 秒。后来我们改成预编译成二进制格式,才把时间压下来。

特性 NDS OpenDRIVE
存储格式 SQLite 数据库 XML 文件
更新方式 差分更新(增量) 全量替换
适用场景 量产车端 仿真、测试
车道级精度 支持(扩展后) 原生支持
嵌入式友好度 高(二进制存储) 低(XML 解析开销大)

4.2 车道模型:从「线」到「面」的思维转变

做传统导航时,我们只关心道路。道路就是一条线。但高精地图不一样,它关心的是车道。一条路可能有 3 条车道,每条车道都有自己的属性:宽度、限速、行驶方向、是否允许变道。

我建议你把车道模型想象成一个「管道」。这个管道有起点、终点、左右边界。每个边界都是一条曲线。嗯,说白了就是 4 条线围起来的一个面。

// 车道模型的数据结构(简化版)
typedef struct {
    uint64_t lane_id;          // 车道唯一 ID
    uint64_t road_id;          // 所属道路 ID
    int32_t  lane_index;       // 车道序号(从右到左:-1, 0, 1...)
    float    width;            // 车道宽度(米)
    float    speed_limit;      // 限速(km/h)
    Polyline3D left_boundary;  // 左边界线
    Polyline3D right_boundary; // 右边界线
    LaneType  type;            // 车道类型(行车道、应急车道、公交车道...)
} LaneModel;

我在项目中遇到过一个问题:车道边界线怎么存?最直接的办法是存一堆离散点。但这样数据量太大了。一条车道几百米,每 10 厘米一个点,那就是几千个点。全国几千万条车道,你算算数据量?

后来我们改用三次样条曲线。只存控制点,运行时实时插值。这样数据量能压缩 90% 以上。代价就是计算量上去了。不过对于现在的车规级芯片来说,这点算力还是扛得住的。

避坑指南: 我曾经在车道宽度上吃过亏。有些路段的宽度是渐变的——比如从 3.5 米逐渐变到 3.0 米。如果你只存一个固定宽度,那车辆定位到这段路时,横向误差会非常大。解决方案是:把宽度也做成一个曲线函数,随里程变化。

4.3 路口模型:最复杂的拓扑节点

路口是高精地图里最复杂的地方。为什么?因为路口里没有车道线。或者说,车道线是断开的。车辆从一条路进入路口,然后从另一条路出去。中间这段怎么走?全靠拓扑关系。

我习惯把路口模型拆成三部分:

  • 进入连接:从哪条车道进入路口
  • 内部路径:在路口内部的行驶轨迹(通常是多条曲线)
  • 退出连接:从路口进入哪条车道

你想想看,一个十字路口,每个方向有 3 条车道。左转、直行、右转,再加上掉头。那这个路口内部就有 3×4×3 = 36 条可能的行驶路径。每条路径都是一条曲线。这数据量,啧啧。

// 路口连接模型
typedef struct {
    uint64_t junction_id;     // 路口 ID
    uint64_t in_lane_id;      // 进入车道 ID
    uint64_t out_lane_id;     // 退出车道 ID
    Polyline3D path;          // 内部行驶路径
    float     path_length;    // 路径长度
    TurnType  turn_type;      // 转向类型(左转、右转、直行、掉头)
    uint32_t  priority;       // 优先级(用于路径规划)
} JunctionConnection;
注意: 路口内部的路径曲线,精度要求比普通车道高得多。因为车辆在路口内没有车道线约束,全靠这条路径引导。如果路径偏差超过 20 厘米,车辆就可能压到路沿或者侵入对向车道。我见过一个测试车,就是因为路口路径标定不准,差点开上人行道。

4.4 拓扑关系:把「死」数据变成「活」路网

数据模型再漂亮,如果没有拓扑关系,那就是一堆死数据。拓扑关系说白了就是「谁跟谁连着」。车道和车道之间、车道和路口之间、路口和道路之间,都得连起来。

我建议你把拓扑关系想象成一个有向图。每个节点是一条车道,每条边是一个连接关系。这个连接关系包含:前驱车道、后继车道、可变更车道(左变道、右变道)。

// 车道拓扑关系
typedef struct {
    uint64_t lane_id;              // 当前车道
    uint64_t predecessor_id;       // 前驱车道(进入当前车道的前一条车道)
    uint64_t successor_id;         // 后继车道(从当前车道出去的下一条车道)
    uint64_t left_neighbor_id;     // 左侧相邻车道(用于变道)
    uint64_t right_neighbor_id;    // 右侧相邻车道
    bool     can_change_left;      // 是否允许向左变道
    bool     can_change_right;     // 是否允许向右变道
    uint64_t junction_id;          // 如果当前车道连接路口,记录路口 ID
} LaneTopology;

这里有个坑:前驱和后继不是一一对应的。一条车道可能有多条前驱(比如两条车道汇入一条),也可能有多条后继(比如一条车道分叉成两条)。所以实际存储时,前驱和后继通常是一个列表,而不是单个 ID。

个人经验: 拓扑关系的构建,最好在数据预处理阶段完成,而不是在车端运行时实时计算。我曾经试过在车端动态构建拓扑,结果发现每次启动都要花 3-5 秒来建立索引。后来改成离线预处理,把拓扑关系直接固化到数据文件中,启动时间降到了 200 毫秒以内。

4.5 总结一下

数据模型设计,说白了就是「怎么把现实世界的路网,用计算机能理解的方式存下来」。NDS 和 OpenDRIVE 是两种主流思路,一个偏量产、一个偏仿真。车道模型要关注边界和宽度,路口模型要关注内部路径,拓扑关系要把所有东西串起来。

嗯,这节内容就到这。下一节我们会聊数据存储与索引——怎么在嵌入式平台上高效地读写这些数据。到时候我会分享一个我亲手优化的 B+ 树索引方案,性能提升 5 倍以上。