ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

室内矢量地图:从CAD图纸到可导航拓扑图的完整指南

室内矢量地图:从CAD图纸到可导航拓扑图的完整指南

简介:这是一套基于HTML5与矢量图形技术实现的室内地图应用源码,面向需要开发商场、机场、展馆等复杂室内导航功能的Web前端与移动端开发者。资源以矢量数据替代传统像素图像,缩放不失真、渲染高效、带宽占用低,并借助Canvas、SVG及Leaflet、OpenLayers等库实现拖拽、缩放、标记与区域高亮等交互。压缩包共171个文件,约2.47MB,以114个JavaScript脚本、8个HTML页面、7个CSS样式、9个JSON数据及PNG、SVG图标资源为主,另含少量Python、XML与字体文件,覆盖地图主页面、样式定义、空间数据与图标素材等模块。已有1355人学习下载,可帮助读者理解室内矢量地图的目录组织与实现思路,掌握商铺点击变色、图层管理与跨平台封装等具体做法,适合作为前端地图项目的参考范例。

1. 室内矢量地图:为什么你的导航在商场里总把你带进消防通道

做过室内定位的人大概都经历过这种尴尬:手机上的蓝点明明已经贴到了店铺门口,导航箭头却指向一堵墙,或者干脆把你导进了员工通道。问题往往不在定位算法,而在底图——你手里那张图,很可能是一张 PNG 或者 CAD 导出的位图,它只有像素,没有语义。室内矢量地图要解决的就是这件事:把楼层平面从「一张画」变成「一张带拓扑关系的图」,让每个店铺、每条走廊、每部电梯都是可被程序查询和计算的对象。

它适合三类人:做商场、机场、医院室内导航的开发者;做机器人室内路径规划、需要轻量级地图格式的工程师;以及手里有一堆 CAD 图纸、想批量转成可编程底图的数据处理人员。这篇文章不讲空泛概念,而是从数据模型一路讲到怎么把一张 CAD 平面图转成能跑路径规划的矢量地图,包括我踩过的坐标系、拓扑断裂和渲染性能的坑。读完你应该能自己搭一条从图纸到可用底图的流水线。

2. 室内矢量地图的数据模型:节点、边和面到底怎么组织

2.1 为什么不能用 GeoJSON 直接套室内场景

很多人第一反应是「矢量地图嘛,用 GeoJSON 不就行了」。室外场景确实可以,道路是线、建筑是面,经纬度一套就完事。但室内有几个室外没有的特性,直接套 GeoJSON 会很难受。

第一是垂直维度。室内是多楼层的,同一个经纬度坐标在 1 楼和 5 楼都存在,GeoJSON 的坐标只有 x/y,你得额外挂一个level属性,而且跨楼层的连通关系(电梯、扶梯、楼梯)没法用纯几何表达。第二是拓扑连通性。室外导航靠路网,室内导航靠的是「门—房间—走廊」的连通图,一个店铺是不是能从当前走廊到达,取决于中间有没有门、门是不是常开。第三是精度量级。室内 POI 的间距常常是米级甚至亚米级,用经纬度浮点数存储会有精度和性能的双重浪费,本地平面坐标系(米为单位)更合适。

所以常见的做法是:几何用本地平面坐标,拓扑单独建图,楼层用独立图层,跨层连通用显式的连接边表示。这套模型不依赖某个特定格式,你可以用自定义 JSON,也可以用带扩展属性的矢量瓦片。

2.2 一张室内矢量地图最少需要哪几类要素

我把室内矢量地图的要素分成四层,从下到上依次是:

图层要素类型作用是否必须
基础几何层面(房间轮廓、走廊轮廓)渲染底图、判断点在哪个区域必须
拓扑层节点 + 边路径规划、连通性判断必须
语义层POI 点、房间属性搜索、分类、展示必须
连接层跨层连接边、门跨楼层导航多层建筑必须

基础几何层负责「看起来对」,拓扑层负责「走得通」,语义层负责「找得到」,连接层负责「上得去」。很多团队只做了第一层和第三层,结果导航能显示但算不出路径,或者算出的路径穿墙,就是因为拓扑层缺失或没和几何对齐。

拓扑层的节点通常放在走廊中心线、门口、路口;边连接相邻节点,权重是实际步行距离。这里有个容易忽略的点:节点不应该放在房间几何中心,而应该放在「可达点」上,比如门口内侧。否则路径会从房间中心直线穿到走廊,视觉上穿墙。

2.3 用 Python 定义一个最小可用的室内地图数据结构

下面这段代码定义一个最小可用的室内矢量地图结构,包含楼层、房间、节点、边和跨层连接。用 dataclass 是为了让字段清晰,实际项目里可以换成 Pydantic 做校验。

from dataclasses import dataclass, field from typing import List, Dict, Optional, Tuple @dataclass class Room: id: str # 房间唯一标识,如 "shop_101" name: str # 展示名称 polygon: List[Tuple[float, float]] # 本地平面坐标,单位米,逆时针 category: str = "unknown" # 语义分类:shop / toilet / exit level: int = 1 # 所在楼层 @dataclass class Node: id: str x: float y: float level: int kind: str = "corridor" # corridor / door / elevator / stairs @dataclass class Edge: src: str # 起点 node id dst: str # 终点 node id weight: float # 步行距离,单位米 bidirectional: bool = True @dataclass class Connection: # 跨层连接,比如电梯连接 1 楼和 5 楼的两个节点 node_a: str node_b: str kind: str = "elevator" # elevator / stairs / escalator @dataclass class IndoorMap: rooms: Dict[str, Room] = field(default_factory=dict) nodes: Dict[str, Node] = field(default_factory=dict) edges: List[Edge] = field(default_factory=list) connections: List[Connection] = field(default_factory=list) def add_room(self, room: Room): self.rooms[room.id] = room def add_node(self, node: Node): self.nodes[node.id] = node def add_edge(self, edge: Edge): self.edges.append(edge)

这段代码的关键在于把「几何」和「拓扑」分开存:Room.polygon只管渲染和区域判断,Node/Edge只管路径计算。两者通过level和空间位置关联,但不强绑定。这样做的直接好处是,当你发现某条路径穿墙时,只需要调整节点位置或补一条边,不用动几何数据。

参数上要注意weight不要直接用欧氏距离,走廊拐弯多的时候欧氏距离会低估实际步行距离。我一般用曼哈顿距离或者沿中心线的折线长度,误差能控制在 10% 以内。bidirectional默认 True,但消防通道、单向门要设成 False,否则导航会给出逆行路线。

3. 从 CAD 图纸到矢量地图:转换流水线的四个阶段

3.1 图纸预处理:图层分离和单位统一

拿到一张 CAD 图纸(DXF 格式最常见),第一件事不是急着解析,而是看图层。正规的图纸会把墙体、门窗、标注、家具分在不同图层,但现实是很多图纸所有东西都在一个图层里,颜色和线型是唯一区分。

预处理阶段要做三件事:一是按图层或颜色过滤,只保留墙体、门窗、房间边界;二是统一单位,CAD 里可能是毫米,也可能是英寸,要统一转成米;三是清理碎线和重复线,CAD 里经常有重叠的线段,不清理会导致后续多边形化失败。

用ezdxf读取和过滤图层的代码如下:

import ezdxf def load_dxf(path: str, keep_layers: List[str]): doc = ezdxf.readfile(path) msp = doc.modelspace() segments = [] for entity in msp: if entity.dxf.layer not in keep_layers: continue if entity.dxftype() == "LINE": start = entity.dxf.start end = entity.dxf.end segments.append(((start.x, start.y), (end.x, end.y))) elif entity.dxftype() == "LWPOLYLINE": pts = [(p[0], p[1]) for p in entity.get_points()] for i in range(len(pts) - 1): segments.append((pts[i], pts[i + 1])) return segments def to_meters(segments, unit_scale=0.001): # CAD 常用毫米,转米 return [((a[0]*unit_scale, a[1]*unit_scale), (b[0]*unit_scale, b[1]*unit_scale)) for a, b in segments]

keep_layers要按实际图纸填,常见命名是WALL、DOOR、ROOM。unit_scale是最容易翻车的地方,0.001 对应毫米转米,如果图纸是英寸,要改成 0.0254。判断单位有个土办法:量一下图纸里一个标准门的宽度,正常是 0.9 米左右,反推比例尺。

3.2 多边形化:把线段拼成房间轮廓

过滤出来的线段是散的,需要拼成闭合多边形才能表示房间。这一步的难点在于线段端点不精确重合,CAD 里两条线看着连在一起,实际坐标差 0.001 毫米,直接按坐标匹配会失败。

常见做法是先做端点吸附(snapping),把距离小于阈值的端点合并成一个点,再构建图结构找环。阈值一般取 1 到 5 毫米,太大房间会变形,太小拼不上。找环可以用 networkx 的cycle_basis,但要注意它会返回很多小环,需要按面积过滤掉噪声。

import networkx as nx from shapely.geometry import Polygon def build_polygons(segments, snap_tol=0.005, min_area=2.0): G = nx.Graph() def key(pt): # 坐标量化到 snap_tol 网格,实现吸附 return (round(pt[0] / snap_tol), round(pt[1] / snap_tol)) for a, b in segments: ka, kb = key(a), key(b) if ka != kb: G.add_edge(ka, kb) polygons = [] for cycle in nx.cycle_basis(G): pts = [(k[0]*snap_tol, k[1]*snap_tol) for k in cycle] poly = Polygon(pts) if poly.is_valid and poly.area >= min_area: polygons.append(poly) return polygons

snap_tol和min_area是两个必须调的参数。min_area设成 2 平方米能过滤掉大部分标注框和家具轮廓,但会误杀小储物间,实际项目里我一般设 1.5 到 3 之间,再人工过一遍。cycle_basis返回的环方向不固定,后续要统一成逆时针,否则渲染时法线方向会乱。

3.3 拓扑生成:从房间轮廓到可导航图

有了房间多边形,还要生成导航图。这一步的核心是「门」的识别:门是连接房间和走廊的通道,找到门的位置,就能在门内侧和走廊中心线之间连边。

门的识别有两种思路。一是从图纸的 DOOR 图层直接读门的位置,准确但依赖图纸规范;二是从几何推断,找两个多边形共享的短边,或者找房间轮廓上的缺口。实际项目里我一般两者结合:有门图层就用,没有就用几何推断兜底。

生成节点时,走廊中心线可以用多边形的骨架线(skeleton)近似,shapely配合scikit-image的skeletonize可以做,但更简单可靠的做法是人工在走廊拐点和门口布点,因为室内走廊通常不复杂,人工布点几十个节点就能覆盖一层楼,比自动骨架线稳定得多。

def connect_rooms_via_doors(rooms, doors, nodes, edges): # doors: List[Tuple[float, float]] 门的位置 for door in doors: # 找门最近的房间和走廊节点 nearest_room = min(rooms, key=lambda r: r.polygon.distance(Point(door))) nearest_node = min(nodes, key=lambda n: (n.x-door[0])**2 + (n.y-door[1])**2) # 在门内侧生成一个 door 节点,连到走廊节点 door_node = Node(id=f"door_{len(nodes)}", x=door[0], y=door[1], level=nearest_room.level, kind="door") nodes.append(door_node) dist = ((door_node.x-nearest_node.x)**2 + (door_node.y-nearest_node.y)**2) ** 0.5 edges.append(Edge(src=door_node.id, dst=nearest_node.id, weight=dist))

这段逻辑的关键是「门节点」的引入。门节点既是房间的可达入口,又是走廊的接入点,路径规划时从房间到走廊必须经过门节点,天然避免了穿墙。weight用实际距离,不要用固定值,否则路径会偏向门多的方向。

3.4 数据校验:三个必查的拓扑一致性指标

转换完成后不能直接用,要跑三个校验。第一是连通性校验:从任意一个节点出发,能不能到达所有节点。用 BFS 跑一遍,如果有孤立子图,说明有房间没接上。第二是穿墙校验:随机采样路径,检查路径线段是否穿过了非门位置的墙体多边形。第三是跨层校验:电梯和楼梯的两端节点是否都正确连接,有没有出现「电梯只连了一层」的情况。

def check_connectivity(nodes, edges): from collections import defaultdict, deque adj = defaultdict(list) for e in edges: adj[e.src].append(e.dst) if e.bidirectional: adj[e.dst].append(e.src) if not nodes: return False start = nodes[0].id visited = {start} q = deque([start]) while q: cur = q.popleft() for nxt in adj[cur]: if nxt not in visited: visited.add(nxt) q.append(nxt) return len(visited) == len(nodes)

这个函数返回 False 就说明图不连通,要去查是哪个房间的门没识别到。实际项目里最常见的断点是消防通道和后勤区域,这些地方图纸上经常画得不完整,需要人工补节点。

4. 避坑指南:室内矢量地图落地时最容易翻车的五件事

4.1 坐标系不统一导致整层偏移

现象:地图渲染出来整体偏移了几米,或者不同楼层的图叠在一起对不齐。原因:CAD 图纸的原点各不相同,有的在建筑角点,有的在图纸左下角,直接按原始坐标渲染就会错位。解决:在预处理阶段统一平移,把每层楼的图纸原点对齐到一个共同的建筑基准点,通常选建筑外墙的某个角点。平移量要记录在元数据里,后续更新图纸时沿用同一基准。

4.2 门识别成墙导致路径绕远

现象:导航给出的路径明明有近路不走,非要绕一大圈。原因:门的位置没识别出来,拓扑图里房间和走廊之间没有边,算法只能走其他门。解决:检查门图层是否存在,如果图纸没有门图层,用几何推断时把「两个多边形共享边长度在 0.6 到 1.5 米之间」作为门的判据,这个区间覆盖了大部分标准门宽。识别后人工抽查几个关键位置。

4.3 跨层连接缺失导致上不去楼

现象:从 1 楼导航到 5 楼,路径算不出来,或者算出来只有 1 楼的部分。原因:电梯和楼梯的跨层连接边没建,或者建了但两端节点不在同一垂直位置。解决:跨层连接要显式建Connection,并且电梯两端节点的 x/y 坐标要一致(电梯井是垂直的),只差 level。楼梯两端坐标可以不同,但要在图纸上确认楼梯的实际起止位置。

4.4 多边形自相交导致渲染破面

现象:地图渲染时某些房间显示成黑色或者破洞。原因:CAD 线段拼接时产生了自相交多边形,三角化失败。解决:用shapely的buffer(0)做一次自修复,或者用make_valid。修复后面积变化超过 5% 的多边形要人工检查,可能是原始线段有问题。

4.5 节点过密导致路径抖动

现象:路径规划结果看起来歪歪扭扭,或者在小范围内反复横跳。原因:走廊里布了太多节点,且节点之间权重计算不准确,算法在多个近似路径间摇摆。解决:走廊节点间距控制在 2 到 5 米,拐点和路口必须布点,直线段中间可以稀疏。权重用实际折线距离,不要用欧氏距离。如果还是抖动,在路径后处理阶段做一次简化,去掉夹角小于 150 度的中间点。

5. 进阶技巧:用 A* 在室内图上跑路径并验证结果

5.1 在拓扑图上实现带楼层切换的 A*

前面建好的图是分层的,路径规划要支持跨层。做法是把所有楼层的节点放在同一个图里,跨层连接作为特殊边,权重加上电梯等待时间或楼梯步行时间。A* 的启发函数用欧氏距离,跨层时启发值加上楼层差乘以层高。

import heapq def astar(indoor_map, start_id, goal_id, level_height=3.5): nodes = indoor_map.nodes adj = {} for e in indoor_map.edges: adj.setdefault(e.src, []).append((e.dst, e.weight)) if e.bidirectional: adj.setdefault(e.dst, []).append((e.src, e.weight)) for c in indoor_map.connections: # 跨层连接,权重按层高估算 w = abs(nodes[c.node_a].level - nodes[c.node_b].level) * level_height adj.setdefault(c.node_a, []).append((c.node_b, w)) adj.setdefault(c.node_b, []).append((c.node_a, w)) def h(nid): a, b = nodes[nid], nodes[goal_id] return ((a.x-b.x)**2 + (a.y-b.y)**2) ** 0.5 + abs(a.level-b.level)*level_height open_set = [(h(start_id), 0, start_id, [start_id])] visited = set() while open_set: f, g, cur, path = heapq.heappop(open_set) if cur == goal_id: return path, g if cur in visited: continue visited.add(cur) for nxt, w in adj.get(cur, []): if nxt not in visited: heapq.heappush(open_set, (g+w+h(nxt), g+w, nxt, path+[nxt])) return None, float("inf")

level_height是层高,用来估算跨层时间,实际项目里电梯和楼梯要分开设不同系数,电梯可以设 0.5 倍层高时间,楼梯设 2 倍。返回的path是节点 id 列表,渲染时把节点坐标连成折线即可。

5.2 用可视化回放验证路径是否穿墙

路径算出来后,一定要做可视化验证。把房间多边形、节点、边和路径画在一张图上,人眼扫一遍就能发现穿墙和绕远。用matplotlib几行代码就能画:

import matplotlib.pyplot as plt from shapely.geometry import Polygon def visualize(indoor_map, path): fig, ax = plt.subplots() for room in indoor_map.rooms.values(): poly = Polygon(room.polygon) x, y = poly.exterior.xy ax.plot(x, y, color="gray", linewidth=0.5) for e in indoor_map.edges: a, b = indoor_map.nodes[e.src], indoor_map.nodes[e.dst] ax.plot([a.x, b.x], [a.y, b.y], color="lightblue", linewidth=0.3) if path: xs = [indoor_map.nodes[n].x for n in path] ys = [indoor_map.nodes[n].y for n in path] ax.plot(xs, ys, color="red", linewidth=2) ax.set_aspect("equal") plt.show()

红色路径如果穿过了灰色房间边界且没有经过门节点,就是拓扑有问题。这个验证步骤我每次更新地图数据后都会跑一遍,比写单元测试直观得多。

5.3 一个我坚持了三年的习惯

每次拿到新图纸,我不会直接跑转换脚本,而是先花十分钟在 CAD 里把图纸看一遍,标记出电梯、楼梯、消防通道和后勤区域的位置。这些地方是自动转换最容易出错的地方,提前知道它们在哪,校验时就能重点检查。转换脚本可以复用,但图纸的「脾气」每张都不一样,这十分钟省下来的返工时间,远比写更多自动化校验划算。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进