Cesium 照片映射到地图的完整实现
在 Cesium 中实现照片映射需要根据照片的元数据(经纬度、高度、方向、焦距等)将照片正确投影到地球表面上。以下是完整的实现方案:
1. 基本照片映射
class PhotoMapper {
constructor(viewer) {
this.viewer = viewer;
this.photoEntities = [];
}
/**
* 添加照片到地图
* @param {Object} photoData 照片数据
* @param {string} photoData.url 图片URL
* @param {number} photoData.longitude 经度(度)
* @param {number} photoData.latitude 纬度(度)
* @param {number} photoData.altitude 高度(米)
* @param {number} photoData.heading 朝向(度,0-360)
* @param {number} photoData.pitch 俯仰角(度)
* @param {number} photoData.roll 翻滚角(度)
* @param {number} photoData.width 图片宽度(像素)
* @param {number} photoData.height 图片高度(像素)
* @param {number} photoData.focalLength 焦距(毫米)
* @param {number} photoData.sensorWidth 传感器宽度(毫米)
*/
async addPhoto(photoData) {
try {
// 计算视野范围
const frustum = this.calculateFrustum(photoData);
// 创建照片实体
const entity = this.createPhotoEntity(photoData, frustum);
this.photoEntities.push(entity);
return entity;
} catch (error) {
console.error('添加照片失败:', error);
throw error;
}
}
/**
* 计算照片的视锥体
*/
calculateFrustum(photoData) {
const { longitude, latitude, altitude, heading, pitch, roll,
width, height, focalLength, sensorWidth } = photoData;
// 计算视野角度
const hFov = 2 * Math.atan(sensorWidth / (2 * focalLength));
const vFov = 2 * Math.atan((sensorWidth * height / width) / (2 * focalLength));
// 转换为弧度
const headingRad = Cesium.Math.toRadians(heading);
const pitchRad = Cesium.Math.toRadians(pitch);
const rollRad = Cesium.Math.toRadians(roll);
return {
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, altitude),
orientation: Cesium.Transforms.headingPitchRollQuaternion(
Cesium.Cartesian3.fromDegrees(longitude, latitude, altitude),
new Cesium.HeadingPitchRoll(headingRad, pitchRad, rollRad)
),
hFov: hFov,
vFov: vFov
};
}
/**
* 创建照片实体
*/
createPhotoEntity(photoData, frustum) {
const { url, width, height } = photoData;
return this.viewer.entities.add({
name: '照片映射',
position: frustum.position,
orientation: frustum.orientation,
ellipse: {
semiMinorAxis: this.calculateCoverageRadius(frustum),
semiMajorAxis: this.calculateCoverageRadius(frustum),
material: new Cesium.ImageMaterialProperty({
image: url,
transparent: true
}),
height: 0,
extrudedHeight: 10 // 轻微突出显示
},
description: this.createDescription(photoData)
});
}
/**
* 计算覆盖半径
*/
calculateCoverageRadius(frustum) {
// 简化的覆盖范围计算
const distance = 1000; // 假设拍摄距离
return distance * Math.tan(frustum.hFov / 2);
}
/**
* 创建描述信息
*/
createDescription(photoData) {
return `
<div class="photo-info">
<img src="${photoData.url}" style="max-width: 200px; max-height: 150px;">
<p>分辨率: ${photoData.width} × ${photoData.height}</p>
<p>位置: ${photoData.latitude.toFixed(6)}, ${photoData.longitude.toFixed(6)}</p>
<p>高度: ${photoData.altitude}米</p>
</div>
`;
}
/**
* 清除所有照片
*/
clearAllPhotos() {
this.photoEntities.forEach(entity => {
this.viewer.entities.remove(entity);
});
this.photoEntities = [];
}
}
2. 使用 EXIF 数据自动映射
class ExifPhotoMapper extends PhotoMapper {
constructor(viewer) {
super(viewer);
}
/**
* 从EXIF数据添加照片
*/
async addPhotoFromExif(imageFile, imageUrl) {
try {
// 解析EXIF数据
const exifData = await this.parseExifData(imageFile);
// 转换为照片数据格式
const photoData = this.exifToPhotoData(exifData, imageUrl);
// 添加照片
return await this.addPhoto(photoData);
} catch (error) {
console.error('EXIF照片映射失败:', error);
throw error;
}
}
/**
* 解析EXIF数据
*/
async parseExifData(imageFile) {
// 这里可以使用exifr或其他EXIF解析库
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e) {
// 实际项目中应该使用exifr.parse()
const fakeExifData = {
GPSLatitude: [39, 54, 54],
GPSLongitude: [116, 24, 14.4],
GPSAltitude: 50,
GPSImgDirection: 45,
FocalLength: 35,
// 其他EXIF字段...
};
resolve(fakeExifData);
};
reader.readAsArrayBuffer(imageFile);
});
}
/**
* EXIF数据转换为照片数据
*/
exifToPhotoData(exifData, imageUrl) {
// 转换GPS坐标
const latitude = this.convertGpsToDecimal(
exifData.GPSLatitude[0],
exifData.GPSLatitude[1],
exifData.GPSLatitude[2]
);
const longitude = this.convertGpsToDecimal(
exifData.GPSLongitude[0],
exifData.GPSLongitude[1],
exifData.GPSLongitude[2]
);
return {
url: imageUrl,
longitude: longitude,
latitude: latitude,
altitude: exifData.GPSAltitude || 0,
heading: exifData.GPSImgDirection || 0,
pitch: 0,
roll: 0,
width: 4000, // 从EXIF获取或默认值
height: 3000,
focalLength: exifData.FocalLength || 35,
sensorWidth: 36 // 全画幅传感器宽度
};
}
/**
* GPS坐标转换
*/
convertGpsToDecimal(degrees, minutes, seconds) {
return degrees + minutes / 60 + seconds / 3600;
}
}
3. 精确的照片投影映射
class PrecisePhotoMapper extends PhotoMapper {
/**
* 精确的照片投影
*/
createPrecisePhotoEntity(photoData, frustum) {
const corners = this.calculatePhotoCorners(photoData, frustum);
return this.viewer.entities.add({
name: '精确照片映射',
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
corners.map(corner => Cesium.Cartesian3.fromRadians(
corner.longitude, corner.latitude, corner.height
))
),
material: photoData.url,
height: 0,
classificationType: Cesium.ClassificationType.TERRAIN
}
});
}
/**
* 计算照片四个角坐标
*/
calculatePhotoCorners(photoData, frustum) {
const { hFov, vFov } = frustum;
const distance = photoData.altitude / Math.sin(Cesium.Math.toRadians(photoData.pitch));
const corners = [
{ x: -1, y: 1 }, // 左上
{ x: 1, y: 1 }, // 右上
{ x: 1, y: -1 }, // 右下
{ x: -1, y: -1 } // 左下
];
return corners.map(corner => {
const direction = this.calculateRayDirection(
corner.x * Math.tan(hFov / 2),
corner.y * Math.tan(vFov / 2),
frustum.orientation
);
const ray = new Cesium.Ray(frustum.position, direction);
const intersection = this.viewer.scene.globe.pick(ray, this.viewer.scene);
if (intersection) {
const cartographic = Cesium.Cartographic.fromCartesian(intersection);
return {
longitude: cartographic.longitude,
latitude: cartographic.latitude,
height: cartographic.height
};
}
// 如果没有交点,使用近似计算
return this.approximateCorner(photoData, corner);
});
}
/**
* 计算射线方向
*/
calculateRayDirection(xOffset, yOffset, orientation) {
const direction = new Cesium.Cartesian3(0, 0, -1); // 相机前方
const rotation = Cesium.Matrix3.fromQuaternion(orientation);
Cesium.Matrix3.multiplyByVector(rotation, direction, direction);
// 应用偏移
const right = new Cesium.Cartesian3();
Cesium.Cartesian3.cross(direction, Cesium.Cartesian3.UNIT_Z, right);
Cesium.Cartesian3.normalize(right, right);
const up = new Cesium.Cartesian3();
Cesium.Cartesian3.cross(right, direction, up);
Cesium.Cartesian3.normalize(up, up);
const result = new Cesium.Cartesian3();
Cesium.Cartesian3.add(
direction,
Cesium.Cartesian3.multiplyByScalar(right, xOffset, new Cesium.Cartesian3()),
result
);
Cesium.Cartesian3.add(
result,
Cesium.Cartesian3.multiplyByScalar(up, yOffset, new Cesium.Cartesian3()),
result
);
return Cesium.Cartesian3.normalize(result, new Cesium.Cartesian3());
}
}
4. 完整的使用示例
// 初始化Cesium Viewer
const viewer = new Cesium.Viewer('cesiumContainer', {
terrainProvider: Cesium.createWorldTerrain()
});
// 创建照片映射器实例
const photoMapper = new PhotoMapper(viewer);
// 示例照片数据
const samplePhoto = {
url: 'path/to/photo.jpg',
longitude: 116.404,
latitude: 39.915,
altitude: 100,
heading: 45,
pitch: -30,
roll: 0,
width: 4000,
height: 3000,
focalLength: 35,
sensorWidth: 36
};
// 添加照片
photoMapper.addPhoto(samplePhoto).then(entity => {
console.log('照片添加成功');
// 飞到照片位置
viewer.flyTo(entity, {
duration: 2,
offset: new Cesium.HeadingPitchRange(0, -0.5, 1000)
});
});
// 添加多个照片
const photos = [
{ /* 照片数据1 */ },
{ /* 照片数据2 */ },
{ /* 照片数据3 */ }
];
photos.forEach(async (photo, index) => {
try {
await photoMapper.addPhoto(photo);
console.log(`照片 ${index + 1} 添加成功`);
} catch (error) {
console.error(`照片 ${index + 1} 添加失败:`, error);
}
});
// 清除所有照片
document.getElementById('clear-btn').addEventListener('click', () => {
photoMapper.clearAllPhotos();
});
5. 样式优化
.photo-info {
padding: 10px;
background: white;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.photo-info img {
border-radius: 3px;
margin-bottom: 10px;
}
.photo-info p {
margin: 5px 0;
font-size: 12px;
color: #666;
}
注意事项
- 坐标系一致性:确保所有坐标使用 WGS84 坐标系
- 单位转换:注意角度单位(度/弧度)和长度单位的转换
- 性能优化:大量照片映射时需要考虑性能问题
- 错误处理:添加适当的错误处理和用户反馈
- 地形影响:考虑地形对照片投影的影响
这个完整的照片映射方案可以根据实际需求进行调整和扩展,支持从简单的椭圆显示到精确的投影映射等多种应用场景。