尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Cesium无人机航拍视频投影跟随:实现与优化

Cesium无人机航拍视频投影跟随:实现与优化 简介这是基于Cesium的无人机航拍视频投影跟随源码包面向Web GIS开发者、三维可视化爱好者及无人机数据展示方向的工程人员。源码实现了将航拍视频实时投影到三维地球并跟随无人机模型移动而移动、旋转而旋转支持设置投影角度、俯仰角等参数通过定时器更新模型经纬度来模拟飞行结构清晰便于二次开发。压缩包共375个文件、6.26MB以107个JavaScript逻辑脚本和29个JSON配置为核心配合141个PNG、53个JPG、27个CSS及SVG图标构建Cesium界面另有HTML入口、GLB无人机模型、WASM文件等资源支撑功能运行。目前已有932人学习下载。下载后需替换视频地址、Cesium Token及无人机模型路径即可在自有项目中复用投影跟随能力适合教学演示、原型验证或实际GIS应用参考。 做三维GIS这一行经常被问到“能不能把无人机拍的视频直接贴到Cesium地球上让摄像头跟着飞机走”我今年手头正好做了一个类似的演示系统就是标题那六个字——cesium无人机航拍视频投影跟随。简单说就是把无人机拍下来的实时或离线视频通过WebGL纹理映射到三维地球表面同时让Cesium场景里的相机实时跟踪无人机的轨迹从空中视角一边看画面一边看画面所对应的真实地形。这个东西非常适合智慧城市巡检、应急指挥、地块变化对比、大屏演示这类场景也适合刚入门Cesium没多久、想在项目里搞点高级感的开发者当练手题。为什么要折腾这件事因为无人机航拍后常规流程是用专业软件做正射影像跑完再切片发布一等就是几小时。在应急、巡检这类场景里时间耗不起。于是就有了“直播式”需求视频不切片、不拼接直接在三维地球上投出来看。这里有两个隐藏问题第一视频是二维影像地球是三维地形两者没有天然的对应关系第二视频跟随不是简单的“把相机锁到飞机坐标”而是要让观察者在三维世界里获得一个稳定的跟随视角。这两点直接决定了后面所有技术选型。1. 需求拆解视频投影只是入口真正的难点在“跟随”1.1 这个功能到底在解决什么问题视频投影跟随听起来像是一个单纯的展示功能但真正落地的时候你会发现它同时踩了三条线数据线、渲染线、交互线。数据线解决的是“飞机在哪、朝哪个方向看、视频里是哪块地”的问题。无人机飞行日志里通常有经纬度、高度、IMU姿态但格式五花八门有的藏在SRT字幕文件里有的是厂商私有格式。你需要把这些数据抽出来统一成Cesium能用的坐标和时间轴。渲染线解决的是“视频怎么变成地球表面的一张动态贴图”的问题。这里涉及视频纹理、材质绑定、投影四边形的动态更新。很多人的第一反应是用rectangle实体但rectangle不支持旋转无人机一转向就露馅。所以必须用polygon而且要动态计算角点。交互线解决的是“相机怎么跟随才不晕、不跳、有临场感”的问题。这是整个项目里最容易拉低体验感的一环。如果你只是简单地把相机绑在飞机坐标上用户看到的会是一块剧烈抖动的画面根本看不出地形关系。我后面会详细说我的“后上方跟拍”方案这也是我试了五六种方案之后留下来的。1.2 技术选型为什么是 Cesium 而不是 Leaflet/MapboxLeaflet和Mapbox在二维地图生态里很成熟但视频投影跟随这个需求需要真实海拔、全球尺度切换、三维模型叠加、动态材质更新这些在Leaflet里做起来会非常吃力。Cesium的优势在于它本质就是一个“时间感知”的三维地球引擎clock机制、CallbackProperty动态属性、地形采样接口全是为这种时变数据准备的。Cesium也有自己的麻烦。热词里那个“cesium中文文档”大家搜来搜去就那几篇老文章很多API在新版本里已经变了。我这次写代码用的是1.100的版本像createWorldTerrainAsync、sampleTerrainMostDetailed这些接口都跟老版本不一样网上很多教程还在用createWorldTerrain和TerrainProvider.fromUrl直接抄会报错。所以我在下文给的代码尽量按新版API来写并标注了兼容性注意点。1.3 整体流程与技术栈整个系统分四条线。数据线从飞行日志或SRT文件解析出轨迹和姿态场景线加载离线地形、影像瓦片或3DTiles视频线把video元素挂到Cesium的材质上交互线实现相机跟随和姿态联动。技术栈就是Cesium加原生JavaScript没有引入vue也不影响vue下无非是把Cesium实例挂到data里核心逻辑完全一致。2. 核心技点解析坐标、视频纹理、相机跟随三个关键环节2.1 坐标与姿态数据的组织无人机数据一般给的是经纬度、高度、yaw偏航角、pitch俯仰角、roll翻滚角。Cesium里坐标用Cartesian3.fromDegrees(lon, lat, height)转换姿态用HeadingPitchRoll表达。这里最容易踩坑的是航向角基准。DJI的yaw以正北为0度、顺时针为正Cesium的heading也是北偏东为正两者方向一致。但部分飞控导出的是弧度部分给的是角度转换时务必统一。如果姿态数据缺失就只能从轨迹点反算heading取当前点与下一个点的经纬度差值用Math.atan2(dLon * cosLat, dLat)换算成角度。这个公式看起来简单但其中cosLat那一项漏掉的话会出现“纬度越高转向越不准”的怪问题。我建议把每一帧的位置和姿态都整理成对象数组通过时间戳插值来取数而不是用setInterval直接按固定频率取。后者在帧率波动时会让跟随画面一跳一跳观感很差。时间戳插值的核心思路是已知起点时间、终点时间、总帧数当前时钟时间对应到轨迹数组的下标再做线性插值。2.2 视频怎么投到地球上VideoSynchronizer 与视频材质Cesium 1.85版本之后提供了VideoSynchronizer它可以把video元素的播放时间和仿真时钟绑定。核心用法是const videoSynchronizer new Cesium.VideoSynchronizer({ clock: viewer.clock, element: videoElement });然后创建一个polygon实体材质用ImageMaterialPropertyimage直接传videoElement。这样视频帧就会按仿真时钟前进暂停、拖动时间轴视频画面和三维场景能保持同步。但要注意ImageMaterialProperty对视频这类外部纹理的支持比较“随缘”。实测中部分显卡驱动会有花屏现象稳妥做法是先把视频用canvas预处理把每一帧画到canvas上再把canvas作为image传给材质。如果对实时性要求不高也可以直接让video静音播放不用VideoSynchronizer让Cesium自己读视频当前帧代价是拖动时间轴时视频不会跟着走。投影四边形不能横平竖直要随无人机的航向旋转。所以polygon的positions要用CallbackProperty动态计算每次返回一个包含四个角点经纬度坐标的数组。四角点的计算逻辑是把投影面想象成以飞机为中心、宽2倍半宽、高2倍半高的矩形再把矩形的四个角点在局部东北天坐标系里做偏移最后换算回经纬度。2.3 让相机跟上无人机本地坐标系下的跟随策略直接viewer.camera.setView({ destination: 飞机坐标 })会得到一种“钉死在飞机正上方”的视角用户只看得到正下方根本看不出地形走向也看不清视频投影和真实地形的叠合关系。我试下来最舒服的跟随方式是“后上方跟拍”相机在无人机后方偏上视线朝向飞机前方或投影区域中心。这个视角在日常航拍里很常见——相当于你坐在另一架伴飞无人机上看着前面那架飞机飞。投影画面刚好落在视野中下部既能看清视频投影又能看清周围三维地形。计算思路是以无人机当前经纬高为原点在局部东北天坐标系下把“后方偏移量”转换成经纬度偏移。给定航向角heading后方偏移的东向分量是-dist * sin(heading)北向分量是-dist * cos(heading)。再把东向北向偏移除以对应的每度米数得到经纬度增量。这个算法每帧执行一次开销很小。相机朝向不要直接看向飞机坐标那样视角会随着飞机抖动而剧烈摆动。更稳的是把目标点设成“若干秒后飞机将到达的位置”也就是说提前一点视角。观察者会看到投影画面从远处向近处流动这种动态感非常明显比盯着一动不动的地图舒服多了。3. 实操过程从零搭建一套可复现的视频投影跟随3.1 准备数据与离线环境有现成DJI视频的话Pose信息通常能通过解析SRT文件拿到网上有不少现成脚本可以抽取。如果没有只能自己造一段仿真轨迹。我这里先把离线地形的事情说清楚现场大屏演示千万别依赖在线地形网络一抖动地形就一块块崩效果非常尴尬。本地地形推荐用CesiumLab或ctb把1.terrain格式切片再通过CesiumTerrainProvider加载。注意Cesium新版改用CesiumTerrainProvider.fromUrl()老的地形服务地址和本地路径经常有证书、跨域问题本地开发建议起一个nginx静态服务把地形瓦片放在terrain目录下配置好跨域头。Cesium离线瓦片同理用geojson或3dtiles都行。如果演示只需要一小块区域直接加载一个没有地形起伏的平面也够用但视频投影会显得很假还是建议把DEM地形加上。3.2 实现代码骨架下面给出一个最小可运行版本的关键代码基于Cesium 1.100的APIconst viewer new Cesium.Viewer(cesiumContainer, { terrainProvider: await Cesium.createWorldTerrainAsync() }); const videoElement document.getElementById(droneVideo); videoElement.muted true; videoElement.play(); const videoSynchronizer new Cesium.VideoSynchronizer({ clock: viewer.clock, element: videoElement }); function computeProjectionPositions(time) { // 根据时间取轨迹点计算四角点见3.3 } const projectionEntity viewer.entities.add({ polygon: { hierarchy: new Cesium.CallbackProperty(() { return computeProjectionPositions(viewer.clock.currentTime); }, false), material: new Cesium.ImageMaterialProperty({ image: videoElement, transparent: true }), classificationType: Cesium.ClassificationType.TERRAIN } });注意classificationType只在Cesium 1.9x版本可用老版本没有这个属性。如果地形起伏大投影面会“漂浮”在空中需要额外修正高度这个我在3.4里详细说。3.3 动态更新与姿态联动computeProjectionPositions里做的事情就是“给定时间找轨迹点算四角”。我写了一个简单的轨迹插值函数const trajectory []; // 从SRT或日志解析出来的轨迹数组每项包含time, lon, lat, height, heading function getInterpolatedState(time) { const ms time.getTime(); const start trajectory[0].time; const end trajectory[trajectory.length - 1].time; const ratio (ms - start) / (end - start); const index ratio * (trajectory.length - 1); const i Math.min(Math.floor(index), trajectory.length - 2); const t index - i; // 对经纬度、高度、heading做线性插值 return { lon: trajectory[i].lon * (1 - t) trajectory[i 1].lon * t, lat: trajectory[i].lat * (1 - t) trajectory[i 1].lat * t, height: trajectory[i].height * (1 - t) trajectory[i 1].height * t, heading: interpolateAngle(trajectory[i].heading, trajectory[i 1].heading, t) }; } function interpolateAngle(a, b, t) { let diff (b - a 180) % 360 - 180; return a diff * t; }interpolateAngle这个函数很关键直接处理了航向角从359度跳到0度的问题。如果不做这个归一化飞机每飞一圈画面就会疯狂旋转一次。接着是四角点计算function computeProjectionPositions(time) { const state getInterpolatedState(time); const halfW 100; // 投影半宽米 const halfH 75; // 投影半高米 const rad Cesium.Math.toRadians(state.heading); const cosLat Math.cos(Cesium.Math.toRadians(state.lat)); const corners [ [-halfW, halfH], [halfW, halfH], [halfW, -halfH], [-halfW, -halfH] ].map(([east, north]) { const latOffset north / 111320; const lonOffset east / (111320 * cosLat); return Cesium.Cartesian3.fromDegrees( state.lon lonOffset, state.lat latOffset, state.height ); }); return corners; }111320这个数字是赤道上一度纬度的米数投影范围小几百米用固定系数足够不必上投影变换公式。四角点要按固定顺序返回我习惯用“左上、右上、右下、左下”的顺序保证polygon不会自交扭曲。机器位置的姿态联动也在这里做可以加一个箭头实体或box实体用HeadingPitchRoll绑定到当前航向角这样画面里能直观看出飞机姿态同时验证视频投影的朝向和飞机航向是否一致。热词里那个“cesium 3d模型姿态”指的就是这个。3.4 投影高度与相机跟随的实现严格贴合地形是这里最费时的一环。我的做法是在computeProjectionPositions里每次计算四角点时用Cesium.sampleTerrainMostDetailed从地形服务取四个角点的高度取平均值作为投影面的基准高度。但这个操作本身是异步的而且开销不小不能每帧都跑。折中方案是每3秒采样一次期间用上一次的采样结果。如果嫌采样麻烦就设一个固定高度把投影面放在轨迹高度减去一个固定值的位置。比如飞机飞行高度80米投影面设到地面附近约10米。视觉上视频画面悬浮在低空勉强可接受但和贴合地形的效果相比少了很多震撼感。相机跟随的核心代码放在scene.postUpdate回调里这是我在调试中发现的最稳定时机viewer.scene.postUpdate.addEventListener(() { const state getInterpolatedState(viewer.clock.currentTime); const dist 120; // 跟随距离米 const heightOffset 60; // 相机高于飞机的高度差 const backRad Cesium.Math.toRadians(state.heading 180); const cosLat Math.cos(Cesium.Math.toRadians(state.lat)); // 相机目标点飞机位置 const target Cesium.Cartesian3.fromDegrees(state.lon, state.lat, state.height); // 相机位置飞机后方 const camPos Cesium.Cartesian3.fromDegrees( state.lon dist * Math.sin(backRad) / (111320 * cosLat), state.lat dist * Math.cos(backRad) / 111320, state.height heightOffset ); viewer.camera.lookAt(target, new Cesium.HeadingPitchRange( Cesium.Math.toRadians(state.heading), Cesium.Math.toRadians(-25), dist )); });这里有个细节lookAt的第一个参数是“观察目标点”不是相机位置。所以我把目标点设置在飞机当前坐标然后用HeadingPitchRange里的range把相机推到后方120米处。Heading给的是观察方向的朝向pitch给-25度让相机略俯视这样投影画面正好落在视野的中下区域。这个写法比直接setView更稳不会因为飞机偏航导致画面突然旋转180度。如果想让相机更平滑可以做一个简单的相机位置插值把当前相机位置和目标位置做一个线性插值每帧只移动20%~30%的距离。这样遇到姿态数据噪声时画面只会轻微晃动不会来回弹跳。4. 常见问题与排查技巧4.1 视频画歪了或者旋转90度先查无人机视频的orientation元数据再查航向角的基准。DJI视频里通常有rotate90的metadataCesium的ImageMaterialProperty会按video元素原始方向绘制两者不一致就会看到画面侧躺。解决方案是给video套一层canvas用ctx.translate和ctx.rotate把视频帧旋转到正方向再把canvas传给ImageMaterialProperty。这个过程在播放前做一次就行不需要每帧旋转。另一个坑是航向角在某个点从359度跳到0度直接用差值会导致一瞬间转一圈。处理办法就是我在3.3里写的interpolateAngle先把角度差归一化再做线性插值。4.2 画面闪烁和抖动闪烁多为外部纹理问题表现是每隔几帧画面闪一下。我踩过的坑是没等video的readyState达到HAVE_ENOUGH_DATA就直接挂到材质里导致纹理内容不稳定。加一个监听在video的canplaythrough事件后再创建Cesium实体基本能解决。抖动来自姿态数据噪声特别是pitch动态变化时投影面会忽大忽小。解决方法是把姿态做滑动平均或者干脆固定pitch角度只让航向角参与投影计算。对于应急演示来说10秒内的飞行路径用固定pitch完全够用。4.3 投影面穿进地形或者浮在空中穿地就是投影面高度低于地形浮在空中就是投影面高度高于地形很多。穿地最直接的办法是把投影面高度整体抬高一个offset比如地形最高点上方20米。浮在空中就降低投影高度或者用3.4里的地形采样。注意classificationType: TERRAIN只会让多边形贴地形表面但视频材质会随之起伏反而会扭曲画面。我的经验是小范围地势平坦区域用贴合地形大范围复杂地形干脆用半透明悬浮方案。悬浮方案里把polygon的透明度调到0.75并在投影面下加一个白色网格辅助线观众一眼就能看出视频覆盖范围的边界。4.4 性能瓶颈视频纹理把GPU吃满了视频纹理在WebGL里消耗不小尤其高清视频。实测一段4K视频直接投影帧率会掉到20fps以下。建议在上传前先用ffmpeg转成1080p、H.264、码率控制在4Mbps以内投影面和视频分辨率匹配即可投影面再大也显示不出4K细节。另一个容易被忽略的是如果同时在页面上渲染多个视频纹理比如多机位对比GPU显存会很快吃满。多机位方案建议用“单视频加多次绘制”或“Canvas画布合并”的方式而不是开多个video元素。Cesium的ImageMaterialProperty支持Canvas作为image所以可以把多个视频画到同一个Canvas上再一次性传给材质。5. 扩展思路让演示不局限于“视频贴地”5.1 多视图对比很多应急指挥项目想看“视频画面”和“三维场景”同屏对比。Cesium里可以开两个viewer共享同一个clock左边放视频画面右边放三维场景。这个比切换视角更直观也避免了相机跟随抖动带来的晕眩感。两个viewer可以共享同一个地球数据源但Entity列表建议分开避免互相拖累性能。具体做法是新建两个Cesium.Viewer实例把其中一个的clock指向另一个的clock这样两者时间轴是同一条线。视频投影只放在第一个viewer里第二个viewer保持独立视角做对比。如果想让对比更精准可以设置viewer1.camera.percentageChanged事件让两个相机的朝向和位置时刻保持一致。5.2 可视域分析和动态光照热词里的“cesium可视域分析”配合视频投影可以做一件很实用的事情把无人机当前位置的视锥可视域画出来直观判断视频覆盖到了哪片区域、哪个山头被挡住。Cesium的camera.frustum可以算出四棱锥结合地形求交就是一套基础动态可视域。如果想让三维场景的光照和无人机拍摄时的真实光线一致打开globe.enableLighting再按拍摄时间设置viewer.clock.currentTimeCesium会自动计算太阳位置。这个动态光照效果对演示画面的真实感提升非常明显而且几乎零成本只需一行配置。我建议把拍摄日期和UTC时间写死在代码里不要用系统当前时间否则和视频里的光线方向对不上。5.3 与 three.js 的融合热词里那个“cesium three.js 共享 gl 上下文”方案是让three.js渲染到Cesium的场景里。如果要在视频投影面上叠加three.js的粒子、雷达扫描等特效共享gl上下文会是一个方向。但我的建议是除非确实需要否则先别混三个引擎项目Debug成本会直线上升。Cesium本身的三维能力已经能覆盖90%的演示需求。雷达扫描可以用entity的polyline加动态环实现粒子和雨雪效果也能用Cesium的粒子系统做。如果非要叠加特效库建议把three.js的特效渲染到一张纹理上再贴回Cesium的地球表面而不是直接共享GL上下文。这样耦合度低代码更好维护出了问题也容易定位。最后分享一个我自己的习惯每次做这种视频投影跟随的演示我都会在数据预处理阶段多花半小时把轨迹、姿态、视频时间戳对齐成一份统一的时间轴。很多项目后来效果出不来不是Cesium不会用而是视频和轨迹根本对不上画面乱飞。只要时间轴一致剩下的都是调参问题——跟随距离、投影宽度、相机俯仰角慢慢调到舒服就好。希望这篇东西能帮你少踩几个坑也欢迎在实际项目中试出更好玩的玩法回来交流。本文还有配套的精品资源点击获取
返回列表