简介这是一套基于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_factorydict) nodes: Dict[str, Node] field(default_factorydict) edges: List[Edge] field(default_factorylist) connections: List[Connection] field(default_factorylist) 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_scale0.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_tol0.005, min_area2.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 polygonssnap_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, keylambda r: r.polygon.distance(Point(door))) nearest_node min(nodes, keylambda n: (n.x-door[0])**2 (n.y-door[1])**2) # 在门内侧生成一个 door 节点连到走廊节点 door_node Node(idfdoor_{len(nodes)}, xdoor[0], ydoor[1], levelnearest_room.level, kinddoor) nodes.append(door_node) dist ((door_node.x-nearest_node.x)**2 (door_node.y-nearest_node.y)**2) ** 0.5 edges.append(Edge(srcdoor_node.id, dstnearest_node.id, weightdist))这段逻辑的关键是「门节点」的引入。门节点既是房间的可达入口又是走廊的接入点路径规划时从房间到走廊必须经过门节点天然避免了穿墙。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_height3.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, (gwh(nxt), gw, 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, colorgray, linewidth0.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], colorlightblue, linewidth0.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, colorred, linewidth2) ax.set_aspect(equal) plt.show()红色路径如果穿过了灰色房间边界且没有经过门节点就是拓扑有问题。这个验证步骤我每次更新地图数据后都会跑一遍比写单元测试直观得多。5.3 一个我坚持了三年的习惯每次拿到新图纸我不会直接跑转换脚本而是先花十分钟在 CAD 里把图纸看一遍标记出电梯、楼梯、消防通道和后勤区域的位置。这些地方是自动转换最容易出错的地方提前知道它们在哪校验时就能重点检查。转换脚本可以复用但图纸的「脾气」每张都不一样这十分钟省下来的返工时间远比写更多自动化校验划算。希望帮到你。本文还有配套的精品资源点击获取