点云 2D 地图(PNG + 元数据)模块设计与实践
概述
背景
室内场景最初通过 Three.js 加载 pcd 点云做 3D 展示,存在两个明显问题:性能吃重(单模型上万平米点云、数十万点,浏览器端渲染与下采样成本高)与不好操作(航线规划、航点拖拽在 3D 视角下交互别扭)。经与算法侧对齐后改为:
后端对点云做高度切片(z 0~5m)与投影渲染,产出一张 2D 俯视图 PNG(白底黑点、轮廓+填充)与一份元数据 JSON;前端用 Leaflet
CRS.Simple平面坐标系把 PNG 作为ImageOverlay铺成底图,代替卫星地图。
同一套地图能力贯穿**航线库(航线规划)、控制台(实时位置/轨迹)、设备管理(机巢原点配置)**三个业务场景,室内/室外双模式互不干扰。
核心特性
| 特性分类 | 功能描述 | 工程价值 |
|---|---|---|
| 平面坐标系 | Leaflet CRS.Simple,lat=Y / lng=X | 不引入经纬度投影,本地直角坐标直算 |
| 配置化数据源 | source Prop 注入,filePath("png;json") 解析,无效自动回退 mock | 换接口零改动,测试/生产无缝切换 |
| 三坐标系分离 | 图框系(显示)/ 机巢相对系(存储)/ ENU 系(KMZ 输出) | 前端只做加减法,旋转归后端,职责清晰 |
| 三态标记 | 默认蓝 / 选中红 / 拖拽橙,图标缓存 | 与室外卫星地图交互体验完全一致 |
| 室内外隔离 | 单一开关 + 互斥实例 + 字段分离 + 切换清零 | 双模式并存、互不干扰 |
| 原点可视化 | H 标记 + 3m 航向箭头,originEditable 受控 | 航线库只读、设备管理可编辑,同组件复用 |
整体架构
| 层级 | 模块 | 职责 |
|---|---|---|
| 数据源层 | apis/pointcloud.ts | 元数据归一化、filePath 解析、mock 兜底、机巢原点读取 |
| 核心 Hook | composables/points/usePointCloudMap.ts | Leaflet 实例生命周期、四层图层渲染、坐标换算、事件订阅 |
| 公共组件 | components/PointCloud2D.vue | Props/Events/Expose 封装;受控 / 内部航点 / 原点配置三种模式 |
| 业务页面 | 航线库 / 控制台 / 设备管理 | 相对↔绝对换算、业务编排、接口对接 |
数据流总览:

坐标约定(核心)
1. 元数据与图框坐标系
{
"x_min": -11.4106,
"x_max": 33.0638,
"y_min": -58.2205,
"y_max": 42.8444,
"width": 445,
"height": 1011,
"resolution": 0.1,
"z_min": 0,
"z_max": 5,
"point_count": 360561,
"render_mode": "轮廓+填充",
"colormap": "白底黑点"
}| 字段 | 含义 | 用途 |
|---|---|---|
x_min/x_max/y_min/y_max | 图框坐标系边界(米) | ImageOverlay bounds |
width/height | 图像像素 | 自洽校验、像素换算 |
resolution | 米/像素(0.1) | 像素 ↔ 本地坐标互转 |
自洽校验:(33.06 − (−11.41)) / 0.1 ≈ 445 = width,bounds 与分辨率天然对齐,无需额外配准。
像素 ↔ 本地坐标(py=0 为图像顶部,对应 y_max):
pixelToLocal(px, py) => ({ x: x_min + px * resolution, y: y_max - py * resolution })
localToPixel(p) => ({ px: (p.x - x_min) / resolution, py: (y_max - p.y) / resolution })2. CRS.Simple 轴向约定
latLng.lat = Y、latLng.lng = X,全模块统一toLatLng(p) = L.latLng(p.y, p.x);bounds = [[y_min, x_min], [y_max, x_max]];- 地图选项:
zoomControl: false(UI 不需要)、doubleClickZoom: false(双击让位给"拖拽模式"交互)、minZoom: -3 / maxZoom: 4。
3. 三套坐标系与机巢原点
| 坐标系 | 作用 | 消费方 |
|---|---|---|
| 图框系(绝对) | 图像显示、marker/线渲染、点击/拖拽回显 | 前端 |
| 机巢相对系 | localX/localY 落库 | 前端存、后端读 |
| ENU 系 | WPML/KMZ 输出 | 后端旋转生成 |
换算公式(前端只做前两条,旋转归后端):
// 存储:相对机巢
localX = clickX - dockX
// 显示:相对 + 原点
absX = localX + dockX
// 后端 KMZ:相对坐标按 yaw 旋转得 ENU(前端不做)
enuX = localX * cos(yaw) - localY * sin(yaw)
enuY = localX * sin(yaw) + localY * cos(yaw)字段复用约定:后端 DjiDeviceBinding 的 originLatitude/originLongitude 复用存储机巢本地 X/Y,originYaw 为坐标系 Y 轴方位角(度,正北=0 顺时针)。前端航向箭头摆位与反算:
arrow = origin + 3m * (sin(yaw), cos(yaw)) // 摆位
yaw = (atan2(dx, dy) * 180 / π + 360) % 360 // 拖拽反算数据源层实现
/** 后端 json key 带尾部空格(如 "x_min "),统一 trim 容错 */
export function normalizeMetadata(raw) {
/* key/value trim */
}
/** 模型 filePath 格式 "…/xxx.png;…/xxx.json",解析出 png+json 才有效 */
export function parsePointCloudFilePath(filePath) {
const png = parts.find((p) => p.endsWith('.png'))
const json = parts.find((p) => p.endsWith('.json'))
return png && json
? { imageUrl: `${API_BASE}${png}`, metadataUrl: `${API_BASE}${json}` }
: null
}
export const isValidSource = (s) =>
!!s?.imageUrl && !!(s.metadataUrl || s.metadata)API_BASE取VITE_APP_BASE_API(缺省/api),与旧版 3D 查看器/api${filePath}的静态资源约定一致;init(el, source)内isValidSource(source) ? source : await fetchPointCloudMapSource(),解析失败自动回退本地 mock,室外/脏数据不炸页面。
三个页面的取数链路差异:
| 页面 | 室内判断 | filePath 来源 | 原点来源 |
|---|---|---|---|
| 航线库 | routeType==='1' 或 modelFormat==='pcd' | 航线 modelFileUrl / 选模型弹窗 | 模型 deviceId → getDevicePosition |
| 控制台 | 设备列表 type===1(1=室内 2=室外) | getInfoByDeviceId(deviceId).filePath(仅室内调用) | getDevicePosition(deviceId) |
| 设备管理 | — | — | 配置弹窗读写 origin 字段 |
控制台 deviceId 的获取:无人机 gatewaySn → 机巢列表同 sn 的机巢 → 其 deviceId/id。
核心 Hook 实现原理
生命周期与重建
init(el, source?) // 建图:CRS.Simple + ImageOverlay + fitBounds + 四层图层 + click + ResizeObserver
reload(source?) // source 变化(切航线/切模型)→ destroy() + init(),事件订阅(Set)在闭包中保留
destroy() // map.remove()、断开 RO、图层置空;onBeforeUnmount(destroy) 防泄漏ResizeObserver → invalidateSize() 解决 flex 布局下容器尺寸变化导致的瓦片错位。
四层图层分离
| 图层 | 内容 | 说明 |
|---|---|---|
wpLayer | 内部航点模式(编号圆点+虚线) | 测试页 addWaypoint 用 |
markersLayer | 受控标记(三态 pin 图标) | 航线库/控制台 |
routesLayer | 受控连线(#409eff 实线) | 同上 |
originLayer | H 标记 + 航向箭头 + 连接线 | 原点可视化 |
各层 clearLayers() 独立重绘,互不牵连。
三态标记与图标缓存
getIcon(isDraggable, isActive) 以 `${isDraggable}-${isActive}` 为 key 缓存 L.Icon,与室外 useMapMarkers 同一套资源与尺寸,保证两图体验一致:
| 状态 | 图标 | iconSize | 触发条件 |
|---|---|---|---|
| 默认蓝 | marker-icon.svg | 25×41 | 普通航点 |
| 选中红 | marker-icon-red.svg | 35×51 | isActive |
| 拖拽橙 | marker-icon-orange.svg | 30×48 | draggable(拖拽模式) |
原点 H 标记受控
originEditable 一个开关同时控制 H 与箭头的 draggable 及 dragend 绑定:航线库传 false 纯展示(杜绝"拖了没反应"的困惑),设备管理传 true 可点击设位、拖 H 移动、拖箭头调 yaw,经 update:origin 回传保存。
事件订阅机制
Set 收集回调 + onXxx 返回取消函数;组件层把 hook 事件原样转发为 emits,业务页按需监听,hook 本身不感知业务。
公共组件 API 参考
Props
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
source | 地图数据源(png/metadata 地址) | PointCloudMapSource | null(回退 mock) |
modelValue | 内部航点模式双向绑定 | LocalWaypoint[] | [] |
drawMode | 十字光标 | boolean | false |
autoAdd | 点击时组件内部自动加点 | boolean | false |
markers | 受控标记 | MapMarker[] | [] |
routes | 受控连线 | MapRoute[] | [] |
originEditable | 原点配置模式(优先于画点) | boolean | false |
origin | 机巢原点 | OriginState | null |
Events
| 事件名 | 说明 | 回调参数 |
|---|---|---|
map-click | 点击地图(图框绝对坐标) | { x, y } |
marker-click / marker-dblclick | 标记单击/双击(进退拖拽模式) | extData |
marker-drag | 拖拽结束 | { id, position: [x, y] } |
update:modelValue / update:origin | 双向绑定回传 | 列表 / 原点 |
ready / error | 加载成功(带 metadata)/ 失败 | — |
Expose
| 方法 | 说明 |
|---|---|
addWaypoint / undo / clear / setWaypoints / getWaypoints | 内部航点模式操作 |
setOrigin / setOriginEditable | 原点设置与编辑开关 |
fit / pixelToLocal / localToPixel | 视图与坐标工具 |
使用实例
航线库(受控模式)——加点由父组件在 map-click 里决定:
<PointCloud2D
:source="mapSource"
:markers="mapMarkers"
:routes="mapRoutes"
:draw-mode="isAddingPoint"
:origin="dockOrigin"
@map-click="handlePointCloudClick"
@marker-dblclick="handleMarkerDblClick"
@marker-drag="handlePointCloudMarkerDrag"
/>设备管理(原点配置):
<PointCloud2D origin-editable v-model:origin="dockOrigin" :source="mapSource" />页面集成实践
航线库:存相对、显绝对
// 显示:computed 里统一转绝对,渲染层不感知坐标系
pos = toAbsolute(wp.localX ?? 0, wp.localY ?? 0)
// 存储:点击/拖拽回传绝对坐标,落库前转相对
const [relX, relY] = toLocal(data.x, data.y)交互链路与室外完全对齐:添加航点按钮 → 十字光标 → 点击加点(coordinateType: 'local',localZ 取航线默认高度兜底);双击航点进拖拽(橙标)→ 拖拽更新 → 双击退出;底部表单编辑 localX/localY/localZ。
控制台:WebSocket 驱动点移动
- 室内判断用设备列表新增字段
type(1=室内);仅室内调getInfoByDeviceId拿filePath、getDevicePosition拿原点,室外零请求并清空参数; - WS 下发
localX/localY(与航点同约定,相对机巢),显示需+ dockOrigin:
function updateIndoorPosition(localX, localY) {
markerPosition.value = [
localX + dockOrigin.value.x,
localY + dockOrigin.value.y,
]
}
// onMessage 分流:室内走 localX/localY + 轨迹追加;室外走 lat/lng + setMapCenter(仅 leaflet 执行)markerPosition/trajectory 为响应式 prop,值一变组件 deep watch 自动重绘,点即实时移动。
设备管理:原点配置闭环
机巢行"原点"按钮 → 弹窗内 origin-editable 交互设值 → deviceApi.editDevice({ sn, originLatitude: x, originLongitude: y, originYaw }) 落库;航线库/控制台下次加载自动生效,所有航点显示位置随新原点整体平移(落库相对值不变,符合"换原点"语义)。
室内外隔离设计
| 隔离维度 | 实现 |
|---|---|
| 开关唯一 | mapType 单一事实来源,模板/换算/事件/表单全部只读它 |
| 实例互斥 | v-if/v-else 销毁重建——Leaflet CRS 创建即固定,WGS84 与 CRS.Simple 不能同实例切换 |
| 字段分离 | longitude/latitude 与 localX/localY 并存,coordinateType 随数据落库,后端 KMZ 按它分 ENU/WGS84 |
| 换算收敛 | 渲染层只认 position [x,y],坐标系语义在 mapMarkers/mapRoutes computed 上游决定 |
| 切换清零 | 切航线/模式统一 exitDragMode / isAddingPoint=false / stopDraw,跨模式引用全部可选链 |
边界场景与踩坑记录
| 问题 | 原因 | 处理 |
|---|---|---|
| 资源全 200 但地图空白 | 外层高度链断裂,Leaflet 容器 0px | .pc-map { position:absolute; inset:0 } + wrap min-height 兜底 |
| metadata 解析异常 | 后端 json key 带尾部空格 | normalizeMetadata 统一 trim |
| 卫星/点云不能同图切换 | CRS 创建即固定 | v-if 互斥、销毁重建 |
| 双击加点误缩放 | doubleClickZoom 默认开启 | 建图时 doubleClickZoom: false |
| 室外模型进点云分支白屏 | filePath 无 png/json | parsePointCloudFilePath 返回 null → mock 兜底 |
| H 标记"能拖但无反应" | 只读页未受控 draggable | originEditable 控制 draggable 与 dragend 绑定 |
| 旧航线位置整体偏移 | 旧数据按绝对坐标存,新约定相对 | 清掉重画/数据迁移(预期行为) |
| TS 2322 类型不兼容 | MapMarker.id 为 string | number | 组件直接复用项目 MapMarker/MapRoute 类型,不自造窄类型 |
后端协同约定
- 航点实体:
localX/localY/localZ/coordinateType("local"|"wgs84"); KmzGeneratorService:waypoints.stream().anyMatch(wp -> "local".equals(wp.getCoordinateType()))→ 整条航线 ENU 模式,takeOffRefPoint取机巢原点,按上文公式旋转;- 机巢实体:
originLatitude/originLongitude(复用存 locX/locY)、originAltitude、originYaw; - 模型接口:
modelLibrary/list增deviceId,getInfoByDeviceId(deviceId)返回含filePath的模型信息。
总结与展望
模块以"一张 PNG + 一份 JSON"撬动了室内场景的 2D 化:后端负责渲染与旋转,前端只承担铺图、加减法换算与交互,三层封装(数据源 / Hook / 组件)让航线库、控制台、设备管理零重复接入。后续可扩展方向:
- 多机巢:航线/任务增加机巢关联字段,取代"第一台机巢/网关反查"的默认策略;
- 清晰度:单图 0.1m/px 放大发糊,可演进为多级切片/瓦片金字塔;
- 数据迁移:历史绝对坐标航线一次性按原点迁移为相对坐标;
- 轨迹抽稀:控制台长航时轨迹点上万时按距离抽稀再入 polyline;
- 原点配置入口收敛:设备管理配置弹窗与地图内
origin-editable已复用同一交互,可抽为独立业务组件供实施人员现场标定使用。