1. 项目从哪里来:为什么需要 lingbot-map
先交代一下背景。我去年在做一个面向商场场景的服务机器人项目,核心功能之一就是帮顾客找路:电梯在哪、洗手间怎么走、某家店铺在哪层。当时我们内部管这个机器人叫 lingbot,而 lingbot-map 就是给它配套的轻量地图与导航模块。
事情起因其实挺尴尬的。第一次做现场演示的时候,机器人被问"最近的无障碍卫生间在哪",它答上来了,但再问"怎么走过去",它只会复读一句"在三楼东侧",完全给不出一条能照着走的路线。那会儿我们才意识到:对话只是皮,底层如果没有一张结构化的地图,机器人就是有嘴没腿。于是我决定自己写一个地图模块,项目代号就叫 lingbot-map。
为什么不直接用市面上的方案?我列了个对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 商业地图 SDK | 功能全、有现成 UI | 依赖第三方服务,数据要上传,商场私有化部署难搞 |
| 开源 GIS 框架 | 成熟、生态大 | 偏室外 GIS,坐标系和数据结构对室内场景太重 |
| SLAM/ROS 地图方案 | 精度高,适合自动驾驶 | 主要服务机器人位姿,业务层拿它做寻路很别扭 |
| 自己写轻量模块 | 完全可控,按需裁剪 | 所有坑自己踩,前期成本高 |
诚实地讲,我选最后一条不是因为自信,而是因为需求足够窄:不需要车道级导航,不需要实时路况,只需要在一栋楼里把"从 A 点到 B 点"这件事说清楚。这个范围下,自己写一个模块比适配一个大而全的框架要快得多。
这个项目适合谁参考?如果你在做服务机器人、室内导览小程序、楼宇资产管理,或者想给自己的 Agent/Bot 加一个"认路"能力,lingbot-map 的拆解思路和踩坑记录应该能帮你省不少时间。
2. 整体设计:先解决坐标系和数据结构
地图模块最容易被低估的工作,不是写寻路算法,而是定坐标系和数据结构。这两件事没想清楚,后面每加一个功能都要返工。
2.1 坐标系:从真实世界到屏幕坐标
室内场景几乎没有可靠的卫星定位,所以 GPS 是靠不住的。我的做法是建立建筑本地的平面坐标系:把每栋建筑的原点定在某个固定角落(通常选主入口的西南角),X 轴指向东,Y 轴指向北,单位用米。这个坐标系的好处是直观,现场用卷尺或激光测距仪量出来的距离可以直接填进数据文件里。
但地图最终要在屏幕上画、要在对话里描述方向,所以还需要一次投影变换。核心公式很简单:
// 本地米制坐标 -> 画布像素坐标 function projectToScreen( point: { x: number; y: number }, canvasCenter: { x: number; y: number }, pixelsPerMeter: number ) { return { sx: canvasCenter.x + point.x * pixelsPerMeter, sy: canvasCenter.y - point.y * pixelsPerMeter, }; }注意 Y 轴要取反,因为数学坐标的 Y 向上,而屏幕坐标的 Y 向下。这个正负号问题我至少栽过三次,每次都是半路发现图形镜像了。
如果你有真实地理坐标数据,比如要把经纬度转换进本地坐标系,可以用高斯-克吕格投影或者直接用等距投影近似。对于单栋建筑这种小范围场景,等距投影的误差完全可忽略,但前提是你得像我们一样,通过实地测量来校准原点,而不是直接从地图软件上肉眼采点。
2.2 数据模型:点和边才是核心
市面上很多室内地图项目喜欢用栅格或图片来做底图,寻路时再用栅格膨胀生成网格。这种方式对机器人运动规划是合理的,但对"给人指路"这个场景来说太重了。我选的是路网图结构,只有两种核心元素:节点和边。
{ "nodes": [ { "id": "n1001", "x": 12.5, "y": 3.2, "floor": 1, "name": "中庭电梯A", "tags": ["elevator"] }, { "id": "n1002", "x": 28.0, "y": 3.2, "floor": 1, "name": "服务台", "tags": ["service"] }, { "id": "n1003", "x": 28.0, "y": 18.6, "floor": 1, "name": "洗手间入口", "tags": ["restroom"] } ], "edges": [ { "from": "n1001", "to": "n1002", "weight": 15.5, "tags": ["main-aisle"] }, { "from": "n1002", "to": "n1003", "weight": 15.4, "tags": ["main-aisle"] } ], "pois": [ { "id": "p001", "name": "某连锁咖啡店", "category": "food", "floor": 1, "linkedNode": "n1005" } ] }这里有几个关键设计决策:
- 节点不承担所有语义。一个位置可以有多个标签,比如"服务台"既是指路目标,也是失物招领的聚合点。节点和 POI 分开,是因为节点是纯几何的,而 POI 是业务概念,混在一起会让图结构变得很脏。
- 边权重不是距离,是通行代价。默认情况下权重是两点间的欧氏距离,但可以叠加因素,比如斜坡、高峰期人流密度、施工禁行。这样处理的好处是,想调整"更偏好主通道"的策略时,不需要改寻路算法,只要在预处理时给特定边加权重即可。
- 楼层分开建模。我用 floor 字段区分楼层,跨楼层的连接(扶梯、电梯)也建模成边,只是这类边会多几个元数据字段,比如"上楼耗时 30 秒"。对室内场景来说,电梯是个很特殊的节点:它是连接,也是等待点,寻路时应该先走到电梯,再换层,而不是拿直线距离去算。
2.3 模块边界划分
整个 lingbot-map 拆成四个子模块,严格单向依赖:
- >function buildAdjacency(edges: EdgeItem[]): Map<string, EdgeItem[]> { const adj = new Map<string, EdgeItem[]>(); for (const e of edges) { if (!adj.has(e.from)) adj.set(e.from, []); if (!adj.has(e.to)) adj.set(e.to, []); adj.get(e.from)!.push(e); adj.get(e.to)!.push({ ...e, from: e.to, to: e.from }); } return adj; }
第三,预处理阶段把通行类别算清楚。比如"轮椅友好路线"和"常规路线"共用一张图,但预处理时就给每条边打上通行标签,寻路时按模式过滤边,而不是在算法内部做一堆 if-else。我对这个设计的评价是:省心,而且语义直观。
3.2 A* 寻路:从入门到调优
寻路算法我选了 A*。原因很简单:室内图规模并不大,几百个节点顶天了,A* 在有良好启发函数时能满足毫秒级响应,而且实现比 D*、跳点搜索这些方案简单得多,维护成本低。
核心实现长这样:
function findPath(graph: Graph, startId: string, goalId: string, mode: TravelMode): PathResult { const open = new MinHeap<NodeItem>((a, b) => a.f - b.f); const gScore = new Map<string, number>(); const parent = new Map<string, string>(); gScore.set(startId, 0); open.push({ id: startId, f: heuristic(startId, goalId) }); while (!open.isEmpty()) { const current = open.pop()!; if (current.id === goalId) return reconstruct(parent, goalId); const neighbors = graph.adjacency.get(current.id) ?? []; for (const edge of neighbors) { if (!edge.allowedModes.includes(mode)) continue; const tentative = gScore.get(current.id)! + edge.weight; if (tentative < (gScore.get(edge.to) ?? Infinity)) { gScore.set(edge.to, tentative); parent.set(edge.to, current.id); open.push({ id: edge.to, f: tentative + heuristic(edge.to, goalId) }); } } } return { status: 'no-route' }; } function heuristic(nodeId: string, goalId: string): number { const n = graph.nodes.get(nodeId)!; const g = graph.nodes.get(goalId)!; return Math.hypot(n.x - g.x, n.y - g.y); }几个调优心得:
- 启发函数用欧氏距离即可。在很多教程里,网格地图的启发函数会用曼哈顿距离,但我们的图是任意连边的路网,曼哈顿距离会高估实际代价,导致 A* 退化。欧氏距离保证了一致性,既快又不至于产生次优解。
- 优先队列必须用二叉堆。我第一版图省事直接用数组加排序,图一到 500 节点就肉眼可见地卡。换成最小堆之后,单次寻路降到了 5 毫秒以内。
- 转弯代价值得加进去。在商场里,一条全是直角的路线走起来比一条平缓的路线累得多。我后来给边的记录里加了两个方向向量,计算 f 值时对转弯节点追加一个惩罚项(比如 3 秒)。这个改动让路线明显更"人性化",实测反馈很好。
3.3 定位与地图匹配
寻路解决的是"怎么走",定位解决的是"在哪",后者才是室内场景真正的痛点。
我们当时用的不是某个黑盒定位 SDK,而是多源输入:比如商场里的蓝牙 Beacon 信号、用户手动选点、或者机器人自身的里程计。所有输入最终都要落到图上,这个动作叫地图匹配。
最基础的地图匹配思路是最近边投影:
function matchToGraph(rawPoint: Point, edges: EdgeItem[]): MatchedPoint { let best: MatchedPoint | null = null; for (const e of edges) { const proj = projectPointToSegment(rawPoint, segmentOf(e)); const dist = hypot(rawPoint.x - proj.x, rawPoint.y - proj.y); if (!best || dist < best.dist) { best = { nodeId: e.from, point: proj, dist }; } } return best!; }这里有个重要细节:匹配的结果不能只是坐标,还要记住匹配到了哪条边。为什么要记边?因为"用户是否偏航"是靠"投影点是否还落在这条边的区间内"来判断的。如果投影点已经滑出边的两端,或者连续几次匹配到的边都不连通,那就该触发重新规划了。
我们把偏航判断做成一个状态机:正常、疑似偏航、确认偏航、重新寻路。注意不能用一次匹配结果就武断地切状态,因为蓝牙信号抖动很常见,我见过用户站在原地,定位点却左右横跳两米的情况。加一个持续 3 秒的确认窗口之后,误报率才降下来。
3.4 可视化与 Bot 对接
渲染层我用 Canvas 画底图和路线,核心原因是可以复用渲染线程,避免 SVG 在大图缩放下出现卡顿。可视化这块有一个原则:渲染层只画图,不做业务判断。
画路线的方式是把节点序列转成一条 Polyline,然后按楼层分段画。每个楼层画一份,楼层切换时只更新当前可见层。路线动画我用的是先算出整条路径,再按固定速度推进一个进度值,这样能保证在机器人拐弯时节奏均匀,不会一卡一顿。
与上层 Bot 的对接走事件接口,而不是互相直接调用。Bot 只关心"用户问了某个 POI",地图模块返回两类结果:
- 结构化路径:节点 ID 序列、每一步的方向描述、预计耗时。
- 可见路线:当前楼层画布上的折线数据。
这样设计的好处是,B 端小程序和机器人端可以共用同一套地图数据,只是把结构化路径翻译成文本或语音的方式不同。
4. 实操过程与踩坑记录
这一节是全文最想让你看的部分。项目前前后后重构了三轮,每一轮都踩了不同的坑。
4.1 第一版:数据格式翻车
最初我把地图数据设计成嵌套结构,POI 挂在节点下面,节点归属挂在楼层下面,楼上楼下一套层级,看起来很优雅。结果一实际填数据就发现,同一个"洗手间"既要在节点列表里出现,又要在 POI 列表里出现,两边的 ID 对不上,直接导致寻路起点找不到目标。
那次翻车后我做了两个决定:一是节点和 POI 彻底分离,POI 只用一个
linkedNode字段指向节点,不在内部嵌套;二是写了一个数据校验脚本,每次发布地图数据前先跑一遍,自动检查 ID 引用完整性。现在回看,这个校验脚本的价值比寻路算法本身还大。4.2 第二版:寻路性能优化
第二版遇到的是性能问题。初版 A* 用数组当优先队列,200 个节点时还能接受,但把整个商场三层的数据全部加载后,寻路最慢能到 300 毫秒。这在对话场景里是可以感知的延迟。
换二叉堆之后问题解决了大半,但真正的优化来自剪枝:跨楼层寻路时,先把所有节点按楼层分桶,寻路过程如果发现目标在另一层,就只在当前楼层加上电梯/扶梯连接点这个子集里搜索"如何走到换乘节点",到了换乘节点再切到目标楼层。这个设计让单次寻路的节点访问量少了 70% 以上。
4.3 第三版:业务对接的教训
第三版的教训来自业务对接。当时 Bot 团队希望地图模块能直接输出"左转、直行 20 米"这样的自然语言指令,我就顺手在地图模块里写了一大堆模板字符串。结果是:地图模块越来越胖,每换一种话术风格就要改核心代码。
后来学乖了,地图模块只输出带方向和距离的转向点序列:
[ { "type": "start", "name": "服务台", "direction": "向东" }, { "type": "turn", "at": "n1002", "direction": "左转", "distanceToNext": 14.3 }, { "type": "arrive", "name": "洗手间入口" } ]至于"左转"怎么用礼貌用语包装,那完全是对话层的事。分层清晰之后,两边迭代都快了很多。
5. 常见问题排查与经验速查
最后整理一份排查清单,全是实操中高频出现的坑。
现象 可能原因 排查与解决办法 路线显示在墙里 节点坐标采集偏了,或边直接穿过了障碍物 用渲染层打开"编辑模式",把底图和路网叠加检查;修正节点坐标后重新发布数据 起点匹配到错误楼层 传感器给的楼层号不可靠,或定位源配置了同 ID 节点 让定位源直接返回楼层号;给跨楼层节点加明确的"楼层切换标记" 寻路结果明显绕路 启发函数用了曼哈顿距离,或边权重没更新 确认启发函数使用欧氏距离;检查是否有长期罢工的电梯仍被赋低权重 偏航误报严重 定位信号抖动,且判定窗口太短 加 3 秒左右确认窗口;匹配时保留上一个匹配边的上下文 图数据更新不生效 HTTP 缓存 / 本地缓存没有失效策略 给地图数据文件加版本号,Bot 启动时校验版本 跨层路线用直线连了 没有把电梯建模成边 建立换乘节点表,确保跨楼层只有通过换乘边才能相连 另外一个容易被忽略的坑是数据精度。室内地图坐标用米做单位,但采集时如果用的不是激光测距而是目测,误差会轻松超过 1 米。给顾客指路时 1 米的误差或许能忍,但匹配和转弯判断会被这 1 米坑得欲哭无泪。我们后期采购了一台便宜的激光测距仪,把主干节点全部重新量了一遍,效果立竿见影。
最后一个经验是:地图不是一次性的资产,而是需要持续维护的活数据。商场两个月换一次店铺布局,三个月调整一次通道,如果不更新,再好的算法也白搭。lingbot-map 后来加了一个数据管理脚本,让现场运营人员可以画线、拖点、发布新版本,这才真正解决了"地图总过期"的问题。
6. 写在最后的个人体会
做 lingbot-map 这个过程里,我最深的体会是:一个地图模块的价值,不在于算法多酷,而在于数据模型是不是简单可靠、接口边界是不是清晰。A* 寻路两天就能写完,但让地图数据活下来、让不同团队都愿意维护它,花的精力远超写算法本身。
如果你也打算给自己的 Bot 或小程序加地图能力,我建议从小切口开始:先只做一栋楼的单层寻路,把数据模型和接口跑顺,再慢慢加楼层、加偏航判断、加可视化。一上来就追求大而全,大概率会在数据校验和业务对接这两座山面前失去耐心。地图这个事,朴素一点,反而走得远。