本文为稀土技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
介绍
当初在倾斜投影建模技术引进公司项目中的时候,老板也曾邀请过一些做数字孪生开发的厂商过来做产品介绍和技术交流,并表示对其中的视频融合有兴趣,问我能不能实现,于是趁此机会也做了一番调研。
视频投射,也有视频投射、视频融合之类的叫法。在地理信息系统(GIS)中,视频融合通常指的地理空间数据与视频数据结合起来进行分析、可视化或展示的过程,这个过程可以用于多种目的,例如监视、安全、环境监测等。
网上能够找到关于视频融合相关介绍,几乎都是基于高空摄像头的案例,就是鹰眼俯瞰的视角,视频画面与地面融合。而关于普通摄像头视频融合的案例却是凤毛麟角,作者不要收费就是想找商务洽谈,找不到太多公开代码因此只能自行摸索,经过几天的研究走了不少弯路,终于能够实现简易版的视频融合效果。
实现思路
首先,视频作为融合素材需要在服务端经过一些降噪、裁剪、调色等处理,然后生成视频流输出到浏览器端;前端的视频展示需要处理视频与三维场景之间的空间位置关系,从而实现在三维实景模型或地形上上投射视频画面。
在视频投射的情况下,根据摄像机镜头的角度,可能会出现一些画面的部分落在地面上,而另一些部分则在半空中。这种情况通常取决于摄像机的视角和地形的形状。
在 Cesium 中,如果您将视频投射到地球表面或地形模型上,画面可能会根据地形的高度和摄像机的角度而有所不同。如果摄像机位于较低的位置,并且倾斜角度较大,那么画面的一部分可能会在地面上。反之,如果摄像机位于较高的位置,并且倾斜角度较小,那么画面可能会更多地显示在半空中。
如上图所示,展示了不同视角下的投射情况。 在处理视频投射时,应该尽量避免出现视觉上的断裂或不连贯性。如果画面的一部分落在半空中,而另一部分落在地面上,可以通过调整摄像机的参数或者适当地裁剪和调整视频画面的显示,以确保整体画面的连贯性和完整性。
名词解释
以下名词大部分为镜头视锥的可调整参数,在使用视锥调整镜头画面时对这些概念需要有比较充分的了解,否则可能会一头雾水。
名词 | 中文名 | 说明 |
---|---|---|
heading | 偏航角 | 摄像机的水平旋转角度,即摄像机指向的方向与地球表面的方向之间的夹角。 |
pitch | 俯仰角 | 摄像机的俯仰角度,即摄像机的仰角或俯视角度。正值表示向下俯视,负值表示向上仰视。 |
rolling | 翻滚角 | 摄像机的翻滚角度,即摄像机绕其前进方向旋转的角度。 |
fov | 视场角 | 视场角,表示摄像机视锥的水平和垂直方向上的可见角度范围。 |
near | 近裁剪面 | 视锥的近裁剪面,表示摄像机视锥中的最近可见点到摄像机的距离。 |
far | 远裁剪面 | 视锥的远裁剪面,表示摄像机视锥中的最远可见点到摄像机的距离。 |
aspectRatio | 宽高比 | 视锥的宽高比,即视锥的水平宽度与垂直高度之比。 |
stRotation | ST 旋转角 | 用于控制视频画面的旋转角度,在 Cesium 中,ST 表示纹理坐标系的 S 和 T 轴,该参数用于指定纹理的旋转角度 |
orientation | 镜头姿态 | 是用于定义对象(如摄像机、实体等)方向的属性。它描述了对象的朝向或者方向,通常用四元数(Quaternion)来表示 |
操作步骤
-
获取设备坐标和安装高度
首先需要找到摄像头的位置,摄像头现实中的位置和高度参数我们很难获取到,且因为地图、倾斜摄影模型、安装时间客观原因会产生非常大的误差,现实中的数据对最终的呈现指导意义不大。因此我们可以直接在地图上拾取该摄像头设备的地理坐标,并通过3DTiles模型外观和现实中拍摄的设备安装的照片,估算设备大致的安装高度。
-
创建一个视频标签videoElement,s1作为视频源,并创建1个投射多边形Polygon1,将videoElement作为材质赋予Polygon1, Polygon1就是视频的“幕布”了。
-
通过摄像机的坐标和安装高度(该位置可以选择与当前镜头的camera一致)创建一个视锥f1,即摄像头的可视范围,每次调整视锥f1,Polygon1的形状和朝向也会同步做调整。
-
获取到摄像头C1的视频截图或者一小段画面s1,调整地图查看器的镜头S1,直到S1有可能“包”住s1的画面,把S1的镜头姿态保存下来,今后每次查看C1的视频画面都会用到。 s1的画面可能完全落在地面上、可能完全落在半空中、可有可能地面和半空中各占一部分,目前我们只讨论前两种情况。
如果完全落在地面上,就取视锥f1和地面的交点作为s1的投射多边形Polygon1顶点;如果完全落在半空中,则取视锥f1的远端面顶点作为s1的投射多边形Polygon1的顶点。 -
开始调整视锥f1的各种参数,直到画面s1和S2彻底融合,此时保存下f1的各种参数Params,这就是C1的镜头姿态。通过S1和Params,我们就能做到视频与地图环境融合。
-
将视频资源改为持续的视频流,这块为视频直播模块的功能非本文重点,就不展开讲了
代码实现
-
代码主框架
import FrustumEditor from './FrustumEditor.js' import VideoScreen from './VideoScreen.js' import * as dat from 'dat.gui' const Cesium = window.Cesium let viewer = null export default { data(){ }, async mounted () { await this.init() }, methods: { async init () { // 创建Cesium的查看器 this.initViewer() // 加载3DTiles图层 await this.init3DTilesLayer() // 创建视锥 this.initFrustum() // 创建视频画面幕布 this.initVideoMesh() this.initGUI() // 鼠标交互 this.initMouseInteract() }, } }
-
创建Cesium的查看器和三维图层
initViewer () { const container = this.$refs.cesiumContainer viewer = new Cesium.Viewer(container, { timeline: true, animation: true, sceneModePicker: true, baseLayerPicker: true, infoBox: true, // 打开自带详情弹窗 shouldAnimate: true // 允许播放动画 }) // 停止时间的进度 viewer.clock.shouldAnimate = false // 开启光照 viewer.scene.globe.depthTestAgainstTerrain = true viewer.scene.light = new Cesium.SunLight({ color: Cesium.Color.WHITE, intensity: 5.0 }) } /** * 加载3D建筑图层 * @returns {Promise<void>} */ async init3DTilesLayer () { const tileset = await Cesium.Cesium3DTileset.fromUrl( sourceMap.lingshan.src, //3dTiles入口文件地址 {} ) viewer.scene.primitives.add(tileset) // 调整图层高度 const translation = this.getTransformMatrix(tileset, { x: 0, y: 0, z: -5 }) tileset.modelMatrix = Cesium.Matrix4.fromTranslation(translation) },
-
创建视锥编辑器示例
initFrustum () { // 初始化视锥 const frustumOptions = sourceMap.videoPOI[this.cameraId] const { frustum, cameraView } = frustumOptions // 切换视锥配置参数 for (const k in frustum) { this.guiControl[k] = frustum[k] } // 切换镜头 viewer.camera.setView({ destination: cameraView.position, orientation: { direction: cameraView.direction, up: cameraView.up, right: cameraView.right } }) // 创建视锥体编辑器 this.frustum = new FrustumEditor({ viewer, options: this.guiControl }) }
视锥编辑器类
/** * 视锥编辑器 */ class FrustumEditor { /** * @typedef {Object} Options 视锥体配置参数 * @property {number} [lng=113.50929592055162] 轴心的经度值 * @property {number} [lat=22.774823711975543] 轴心的纬度值 * @property {number} [height=10] 轴心的高度 * @property {number} [heading=0] 相机的方向角 * @property {number} [pitch=0] 相机的俯仰角 * @property {number} [roll=0] 相机的翻滚角 * @property {number} [fov=30] 相机的视场角 * @property {number} [near=0.1] 相机的近裁剪面 * @property {number} [far=30] 相机的远裁剪面 * @property {number} [aspectRatio=1.2] 相机的宽高比 */ options = { lng: 113.50929592055162, lat: 22.774823711975543, height: 10, heading: 0, pitch: 0, roll: 0, fov: 30, near: 0.1, far: 30, aspectRatio: 1.2 } // 场景查看器 viewer = null // 视锥体图元实例 instance = null /** * 构造函数 * @param config {Object} * @param config.viewer {Cesium.Viewer} 场景查看器 * @param config.options {Object} 视锥体参数配置 */ constructor (config) { const { viewer, options } = config if (!viewer || !options) { console.error('Missing required parameters when new FrustumEditor') return } // 配置参数覆盖默认值 for (const key in this.options) { this.options[key] = options[key] } this.viewer = viewer this.create() } /** * 修改配置参数 * @param name * @param newValue */ setOption (name, newValue) { if (this.options.hasOwnProperty(name)) { this.options[name] = newValue } } /** * 创建新的实例 */ create () { const { viewer } = this // 视锥体方向参数 const { lng, lat, height, heading, pitch, roll, fov, near, far, aspectRatio } = this.options // 轴心位置 const position = Cesium.Cartesian3.fromDegrees(lng, lat, height) // 朝向姿态 const hpr = new Cesium.HeadingPitchRoll( Cesium.Math.toRadians(heading), Cesium.Math.toRadians(pitch), Cesium.Math.toRadians(roll) ) // 透视的视锥体类型 const frustum = new Cesium.PerspectiveFrustum({ fov: Cesium.Math.toRadians(fov), aspectRatio, near, far }) // 将相对于局部坐标系的位置和方向转换为相对于固定坐标系的位置和方向 const fixedFrameTransform = Cesium.Transforms.localFrameToFixedFrameGenerator('north', 'east') // 获得相对于大地图的转置矩阵 const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame( position, hpr, Cesium.Ellipsoid.WGS84, fixedFrameTransform ) // 视锥体轮廓 const geometry = new Cesium.FrustumOutlineGeometry({ frustum, origin: Cesium.Cartesian3.ZERO, // 默认值 orientation: Cesium.Quaternion.IDENTITY, // 默认值 vertexFormat: Cesium.VertexFormat.POSITION_ONLY }) // 几何实例对象 const instanceGeo = new Cesium.GeometryInstance({ geometry, id: 'FrustumGeometry', attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor( new Cesium.Color(1.0, 1.0, 1.0, 0.8) ) } }) // 在地图中创建视椎体图元 const primitive = new Cesium.Primitive({ geometryInstances: [instanceGeo], modelMatrix, // 控制位置和朝向 releaseGeometryInstances: false, // 渲染后不释放geometryInstances appearance: new Cesium.PerInstanceColorAppearance({ closed: true, flat: true }), asynchronous: false // 非异步构建 }) viewer.scene.primitives.add(primitive) this.instance = primitive // 给图元加上自定义属性,以方便被检索 primitive.customType = '_frustumEditor' } /** * 清除实例 */ clear () { const { viewer } = this viewer.scene.primitives._primitives.filter(item => { return item.customType === '_frustumEditor' }).forEach(item => { viewer.scene.primitives.remove(item) }) this.instance = null } /** * 更新视锥编辑器 */ update () { this.clear() this.create() } /** * 获取视锥体远端面顶点坐标 * @param drawPoint {Boolean} 是否绘制顶点到地图 * @return {Array} */ getCorners (drawPoint = false) { if (this.instance == null) { return [] } const primitive = this.instance // 获取 primitive 的几何体实例 const instanceGeo = primitive.geometryInstances[0] // 获取几何体实例的几何体对象, 关键! const geometry = Cesium.FrustumOutlineGeometry.createGeometry(instanceGeo.geometry) // 获取几何体的顶点属性 const attributes = geometry.attributes // 获取顶点属性中的位置属性 const positions = attributes.position.values // 获取图元转置矩阵 const modelMatrix = primitive.modelMatrix // 清空顶点几何体 // this.clearSphere() const resultArr = [] // 前4个顶点刚好为远端面顶点 for (let i = 3 * 4; resultArr.length < 4; i += 3) { // 创建顶点坐标的 Cartesian4 对象 const x = positions[i] const y = positions[i + 1] const z = positions[i + 2] const vertex = new Cesium.Cartesian4(x, y, z, 1.0) // 应用模型矩阵到顶点坐标 Cesium.Matrix4.multiplyByPoint(modelMatrix, vertex, vertex) // 将顶点坐标转换为地理坐标 const cartesian3 = Cesium.Cartesian3.fromCartesian4(vertex) const cartographic = Cesium.Cartographic.fromCartesian(cartesian3) resultArr.push(cartographic) } return resultArr } /** * 显示或隐藏视锥 * @param targetVal {Boolean} */ toggle (targetVal) { const { viewer } = this viewer.scene.primitives._primitives.filter(item => { return item.customType === '_frustumEditor' }).forEach(item => { item.show = typeof targetVal === 'boolean' ? targetVal : !item.show }) } } export default FrustumEditor
-
创建视频画面幕布
initVideoMesh () { const { stRotation } = this.guiControl // video里包含了src视频地址和name摄像头安装地址 const { video } = sourceMap.videoPOI[this.cameraId] this.videoScreen = new VideoScreen({ viewer, options: { src: video.src, stRotation // 视频画面的旋转角度 } }) }
视频画面幕布类
/** * 视频幕布 */ class VideoScreen { // 视频标签 videoElement = null viewer = null // 投射多边形 curMesh = null /** * @typedef {Object} Options 视频幕布配置参数 * @property {String} src 视频源地址 * @property {Array} [positions=[]]] 视频幕布几何体顶点坐标 * @property {number} [stRotation=90.0] 几何体纹理的旋转角度 * @property {Boolean} [tapGround=false] 几何体是否完全贴地 */ options = { src: '', positions: [], stRotation: 0.0, tapGround: false } constructor (config) { const { viewer, options } = config if (!viewer || !options) { console.error('Missing required parameters when new FrustumEditor') return } this.viewer = viewer for (const key in options) { this.setOption(key, options[key]) } this.init() } init () { const { viewer } = this const { src, tapGround, positions, stRotation } = this.options // 创建视频元素 const videoElement = document.createElement('video') videoElement.src = src videoElement.loop = true videoElement.muted = true videoElement.autoplay = true videoElement.preload = 'auto' videoElement.addEventListener('canplaythrough', function () { // 视频可以播放时触发的逻辑 videoElement.play() }) this.videoElement = videoElement // 创建Polygon const mesh = viewer.entities.add({ polygon: { hierarchy: new Cesium.PolygonHierarchy(positions), material: videoElement, stRotation: Cesium.Math.toRadians(stRotation), perPositionHeight: !tapGround // 启用每个位置的高度 } }) this.curMesh = mesh } /** * 设置纹理旋转角度 * @param {Number} value 角度值 */ setStRotation (value) { this.curMesh.polygon.stRotation = Cesium.Math.toRadians(value) } /** * 设置属性值 * @param {String} name * @param {*} value */ setOption (name, value) { if (this.options.hasOwnProperty(name)) { this.options[name] = value } } /** * 改变姿态 * @param positions {Array} 新的顶点坐标 */ transform (positions) { if (this.curMesh) { // 更新VideoPolygon的顶点 this.curMesh.polygon.hierarchy = new Cesium.PolygonHierarchy(positions) } } /** * 获取地球面与线的交点 * @param p1 {lng,lat,height} 线的起点 * @param p2 {lng,lat,height} 线的终点 * @return {null|*} */ getInterPointByLine (p1, p2) { const { viewer } = this const startPoint = p1 instanceof Cesium.Cartographic ? p1 : Cesium.Cartographic.fromDegrees(p1.lng, p1.lat, p1.height) // 替换为起点的经纬度和高度 const endPoint = p2 instanceof Cesium.Cartographic ? p2 : Cesium.Cartographic.fromDegrees(p2.lng, p2.lat, p2.height) // 替换为终点的经纬度和高度 // 创建起点和终点的笛卡尔坐标 const startCartesian = viewer.scene.globe.ellipsoid.cartographicToCartesian(startPoint) const endCartesian = viewer.scene.globe.ellipsoid.cartographicToCartesian(endPoint) // 通过原点和方向 创建射线 const direction = Cesium.Cartesian3.subtract(endCartesian, startCartesian, new Cesium.Cartesian3()) const ray = new Cesium.Ray(startCartesian, direction) // 计算射线与地球表面的交点 const result = Cesium.IntersectionTests.rayEllipsoid(ray, viewer.scene.globe.ellipsoid) if (result && result.start !== result.stop) { // 计算交点的位置 console.log(`start ${result.start}, stop ${result.stop}`) const intersectionPoint = Cesium.Ray.getPoint(ray, result.start) // 将交点的笛卡尔坐标转换为地理坐标 const intersectionCartographic = Cesium.Cartographic.fromCartesian(intersectionPoint) return intersectionCartographic } else { console.log(`该方向上无交点 ${p1}, ${p2}`) return null } } /** * 显示隐藏 * @param targetVal {Boolean} */ toggle (targetVal) { const { curMesh } = this if (curMesh) { curMesh.show = typeof targetVal === 'boolean' ? targetVal : !curMesh.show } } } export default VideoScreen
-
初始参数配置面板与视锥参数关联
// 参数设置控件 initGUI () { this.gui = new dat.GUI() const { guiControl, gui } = this const folder1 = gui.addFolder('视锥', { closed: false }) folder1.open() folder1.add(guiControl, 'heading', -180, 180).step(0.1).onChange((value) => { this.frustum.setOption('heading', value) this.updateFrustum() }) folder1.add(guiControl, 'pitch', -180, 180).step(0.1).onChange((value) => { this.frustum.setOption('pitch', value) this.updateFrustum() }) folder1.add(guiControl, 'roll', -180, 180).step(0.1).onChange((value) => { this.frustum.setOption('roll', value) this.updateFrustum() }) folder1.add(guiControl, 'fov', 1, 120).onChange((value) => { this.frustum.setOption('fov', value) this.updateFrustum() }) folder1.add(guiControl, 'near', 0.1, 10).onChange((value) => { this.frustum.setOption('near', value) this.updateFrustum() }) folder1.add(guiControl, 'far', 1, 200).onChange((value) => { this.frustum.setOption('far', value) this.updateFrustum() }) folder1.add(guiControl, 'aspectRatio', 0.5, 2.0).step(0.001).onChange((value) => { this.frustum.setOption('aspectRatio', value) this.updateFrustum() }) folder1.add(guiControl, 'enablePick').name('拾取坐标').onChange((value) => { console.log(`${value ? '开启' : '关闭'}拾取坐标`) }) folder1.add(guiControl, 'height', 0, 100).name('原点高度').onChange((value) => { this.frustum.setOption('height', value) this.updateFrustum() }) folder1.add(guiControl, 'stRotation', -180, 180,).name('镜头旋转').onChange((value) => { this.frustum.setOption('stRotation', value) this.videoScreen.setStRotation(value) }) folder1.add(guiControl, 'useGroundInsert').name('使用地面交点').onChange((value) => { this.updateFrustum() }) }, updateFrustum () { this.frustum.update() // 获取视锥体的远端顶点 const corners = this.frustum.getCorners() // 初始化视频幕布 if (!this.videoScreen) { this.initVideoMesh() } // 更新视频幕布的顶点,从而改变它的姿态 this.updateVideoMesh(corners) }, // 创建视频融合面 initVideoMesh () { const { stRotation } = this.guiControl const { video } = sourceMap.videoPOI[this.cameraId] this.videoScreen = new VideoScreen({ viewer, options: { src: video.src, stRotation } }) }, // 调整视频幕布的姿态 updateVideoMesh (frustumCorners) { // 绘制视锥与地面交点 const { lng, lat, height, useGroundInsert, drawVertex } = this.guiControl let interPoints = [] if (useGroundInsert) { console.log('useGroundInsert') // 1.使用与地面的交点作为新顶点 frustumCorners.forEach(corner => { // 检测视锥4条边线与地面的交点 const interPoint = this.getInterPointByLine({ lng, lat, height }, corner) if (interPoint) { interPoints.push(interPoint) } }) } else { // 2.使用视锥的远端点作为新顶点 interPoints = [...frustumCorners] } console.log(interPoints) // 绘制顶点 this.clearSphere() if (drawVertex) { interPoints.forEach(cartographic => { this.drawSphere(Cesium.Math.toDegrees(cartographic.longitude), Cesium.Math.toDegrees(cartographic.latitude), cartographic.height, 6) }) } if (this.videoScreen) { const newPositions = interPoints.map(cartographic => { return Cesium.Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, cartographic.height) }) // 更新VideoPolygon的顶点 this.videoScreen.transform(newPositions) } }
-
实现镜头之间的跳转 (1)获取目标摄像机C1的视频资源地址、视锥参数Params1、用户镜头视角等 (2)将当前视锥按照Params1的参数做变换,包括移动位置、朝向等等 (3)以变换后的视锥为基础,变换视频幕布VideoScreen,调整幕布的顶点位置、纹理旋转角度 (4)将用户镜头移动到目标摄像机C1的视角位置,让视频画面与场景画面叠加融合
/** * 移动到指定的摄像头视锥 * @param id {String} 设备id */ flyToCamera (id) { // 1. 获取到目标摄像机的用户视角、视频资源、视锥参数 const { cameraView, video, frustum } = sourceMap.videoPOI[id] // 隐藏视频 this.toggleVideoMesh(true) // 更新视锥几何参数 for (const key in frustum) { const newValue = frustum[key] this.guiControl[key] = newValue this.frustum.setOption(key, newValue) } // 2. 变化视锥 // 3. 变换视频幕布VideoScree this.updateFrustum() // 3. 更新视频资源 if (this.videoScreen) { this.videoScreen.videoElement.pause() this.videoScreen.videoElement.src = video.src this.videoScreen.setStRotation(frustum.stRotation) } // 4. 镜头过度到设备的视角 viewer.camera.flyTo({ destination: cameraView.position, orientation: { direction: cameraView.direction, up: cameraView.up, right: cameraView.right }, complete: () => { // 隐藏视锥,显示视频幕布 this.toggleFrustum(false) this.toggleVideoMesh(true) } }) },
功能扩展
-
在统的视频监控系统中,监管人员需要同时观看多个分镜头画面,并且很难将零散的分镜头视频与其实际地理位置相对应,多路视频的拼接可以实现较大范围的视频画面,符合现实中的分区块巡查业务。
-
目前的方法只能针对固定视角的摄像头,如果是可操作摄像头的话,则需要同步前端和终端设备的朝向变换角度。碍于环境所限,只能通过人工方式调整镜头参数对齐,关于如何使用空间的透视算法实现自动对齐画面,可以在今后具体的项目中做尝试。
-
与AI模式识别的结合,实时展示计算人流、车流的进出情况统计,进而在三维场景中能够观察到对应的标注,也是有趣的尝试。
总结
经过这次调研,除了对视频融合的实现方案有了实践性的认知,最主要的收获是掌握了对Cesium中图元Primitive的理解和使用。Primitive 是对几何图元和材质的封装,它们可以是点、线、多边形或者其他几何体,可以用Primitive来灵活表示地形、建筑物、道路、水域等。通过使用 Primitive,确实可以做到高性能的地理可视化应用,并实现丰富的交互和动画效果。