跳转到内容

点云 2D 地图(PNG + 元数据)模块设计与实践

刀刀
0字
0分钟
9/7/2026

概述

背景

室内场景最初通过 Three.js 加载 pcd 点云做 3D 展示,存在两个明显问题:性能吃重(单模型上万平米点云、数十万点,浏览器端渲染与下采样成本高)与不好操作(航线规划、航点拖拽在 3D 视角下交互别扭)。经与算法侧对齐后改为:

后端对点云做高度切片(z 0~5m)与投影渲染,产出一张 2D 俯视图 PNG(白底黑点、轮廓+填充)与一份元数据 JSON;前端用 Leaflet CRS.Simple 平面坐标系把 PNG 作为 ImageOverlay 铺成底图,代替卫星地图。

同一套地图能力贯穿**航线库(航线规划)、控制台(实时位置/轨迹)、设备管理(机巢原点配置)**三个业务场景,室内/室外双模式互不干扰。

核心特性

特性分类功能描述工程价值
平面坐标系Leaflet CRS.Simplelat=Y / lng=X不引入经纬度投影,本地直角坐标直算
配置化数据源source Prop 注入,filePath("png;json") 解析,无效自动回退 mock换接口零改动,测试/生产无缝切换
三坐标系分离图框系(显示)/ 机巢相对系(存储)/ ENU 系(KMZ 输出)前端只做加减法,旋转归后端,职责清晰
三态标记默认蓝 / 选中红 / 拖拽橙,图标缓存与室外卫星地图交互体验完全一致
室内外隔离单一开关 + 互斥实例 + 字段分离 + 切换清零双模式并存、互不干扰
原点可视化H 标记 + 3m 航向箭头,originEditable 受控航线库只读、设备管理可编辑,同组件复用

整体架构

层级模块职责
数据源层apis/pointcloud.ts元数据归一化、filePath 解析、mock 兜底、机巢原点读取
核心 Hookcomposables/points/usePointCloudMap.tsLeaflet 实例生命周期、四层图层渲染、坐标换算、事件订阅
公共组件components/PointCloud2D.vueProps/Events/Expose 封装;受控 / 内部航点 / 原点配置三种模式
业务页面航线库 / 控制台 / 设备管理相对↔绝对换算、业务编排、接口对接

数据流总览:

数据流总览

坐标约定(核心)

1. 元数据与图框坐标系

json
{
  "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):

ts
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 = YlatLng.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 输出后端旋转生成

换算公式(前端只做前两条,旋转归后端):

ts
// 存储:相对机巢
localX = clickX - dockX
// 显示:相对 + 原点
absX = localX + dockX
// 后端 KMZ:相对坐标按 yaw 旋转得 ENU(前端不做)
enuX = localX * cos(yaw) - localY * sin(yaw)
enuY = localX * sin(yaw) + localY * cos(yaw)

字段复用约定:后端 DjiDeviceBindingoriginLatitude/originLongitude 复用存储机巢本地 X/YoriginYaw 为坐标系 Y 轴方位角(度,正北=0 顺时针)。前端航向箭头摆位与反算:

ts
arrow = origin + 3m * (sin(yaw), cos(yaw))     // 摆位
yaw   = (atan2(dx, dy) * 180 / π + 360) % 360  // 拖拽反算

数据源层实现

ts
/** 后端 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_BASEVITE_APP_BASE_API(缺省 /api),与旧版 3D 查看器 /api${filePath} 的静态资源约定一致;
  • init(el, source)isValidSource(source) ? source : await fetchPointCloudMapSource()解析失败自动回退本地 mock,室外/脏数据不炸页面。

三个页面的取数链路差异:

页面室内判断filePath 来源原点来源
航线库routeType==='1'modelFormat==='pcd'航线 modelFileUrl / 选模型弹窗模型 deviceIdgetDevicePosition
控制台设备列表 type===1(1=室内 2=室外)getInfoByDeviceId(deviceId).filePath仅室内调用getDevicePosition(deviceId)
设备管理配置弹窗读写 origin 字段

控制台 deviceId 的获取:无人机 gatewaySn → 机巢列表同 sn 的机巢 → 其 deviceId/id


核心 Hook 实现原理

生命周期与重建

ts
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 实线)同上
originLayerH 标记 + 航向箭头 + 连接线原点可视化

各层 clearLayers() 独立重绘,互不牵连。

三态标记与图标缓存

getIcon(isDraggable, isActive)`${isDraggable}-${isActive}` 为 key 缓存 L.Icon,与室外 useMapMarkers 同一套资源与尺寸,保证两图体验一致:

状态图标iconSize触发条件
默认蓝marker-icon.svg25×41普通航点
选中红marker-icon-red.svg35×51isActive
拖拽橙marker-icon-orange.svg30×48draggable(拖拽模式)

原点 H 标记受控

originEditable 一个开关同时控制 H 与箭头的 draggabledragend 绑定:航线库传 false 纯展示(杜绝"拖了没反应"的困惑),设备管理传 true 可点击设位、拖 H 移动、拖箭头调 yaw,经 update:origin 回传保存。

事件订阅机制

Set 收集回调 + onXxx 返回取消函数;组件层把 hook 事件原样转发为 emits,业务页按需监听,hook 本身不感知业务。


公共组件 API 参考

Props

参数说明类型默认值
source地图数据源(png/metadata 地址)PointCloudMapSourcenull(回退 mock)
modelValue内部航点模式双向绑定LocalWaypoint[][]
drawMode十字光标booleanfalse
autoAdd点击时组件内部自动加点booleanfalse
markers受控标记MapMarker[][]
routes受控连线MapRoute[][]
originEditable原点配置模式(优先于画点)booleanfalse
origin机巢原点OriginStatenull

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 里决定:

vue
<PointCloud2D
  :source="mapSource"
  :markers="mapMarkers"
  :routes="mapRoutes"
  :draw-mode="isAddingPoint"
  :origin="dockOrigin"
  @map-click="handlePointCloudClick"
  @marker-dblclick="handleMarkerDblClick"
  @marker-drag="handlePointCloudMarkerDrag"
/>

设备管理(原点配置)

vue
<PointCloud2D origin-editable v-model:origin="dockOrigin" :source="mapSource" />

页面集成实践

航线库:存相对、显绝对

ts
// 显示: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=室内);仅室内getInfoByDeviceIdfilePathgetDevicePosition 拿原点,室外零请求并清空参数;
  • WS 下发 localX/localY(与航点同约定,相对机巢),显示需 + dockOrigin
ts
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/latitudelocalX/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/jsonparsePointCloudFilePath 返回 null → mock 兜底
H 标记"能拖但无反应"只读页未受控 draggableoriginEditable 控制 draggable 与 dragend 绑定
旧航线位置整体偏移旧数据按绝对坐标存,新约定相对清掉重画/数据迁移(预期行为)
TS 2322 类型不兼容MapMarker.idstring | number组件直接复用项目 MapMarker/MapRoute 类型,不自造窄类型

后端协同约定

  • 航点实体:localX/localY/localZ/coordinateType("local"|"wgs84")
  • KmzGeneratorServicewaypoints.stream().anyMatch(wp -> "local".equals(wp.getCoordinateType())) → 整条航线 ENU 模式,takeOffRefPoint 取机巢原点,按上文公式旋转;
  • 机巢实体:originLatitude/originLongitude(复用存 locX/locY)、originAltitudeoriginYaw
  • 模型接口:modelLibrary/listdeviceIdgetInfoByDeviceId(deviceId) 返回含 filePath 的模型信息。

总结与展望

模块以"一张 PNG + 一份 JSON"撬动了室内场景的 2D 化:后端负责渲染与旋转,前端只承担铺图、加减法换算与交互,三层封装(数据源 / Hook / 组件)让航线库、控制台、设备管理零重复接入。后续可扩展方向:

  1. 多机巢:航线/任务增加机巢关联字段,取代"第一台机巢/网关反查"的默认策略;
  2. 清晰度:单图 0.1m/px 放大发糊,可演进为多级切片/瓦片金字塔;
  3. 数据迁移:历史绝对坐标航线一次性按原点迁移为相对坐标;
  4. 轨迹抽稀:控制台长航时轨迹点上万时按距离抽稀再入 polyline;
  5. 原点配置入口收敛:设备管理配置弹窗与地图内 origin-editable 已复用同一交互,可抽为独立业务组件供实施人员现场标定使用。

贡献者

The avatar of contributor named as 刀刀 刀刀

页面历史

刀刀博客累计访客 人;文档累计访问量共