Merge branch 'develop' of http://222.212.85.86:8222/bdzl2/ai_project_v1 into develop

This commit is contained in:
martin 2026-01-14 07:34:21 +08:00
commit eedca6cd50
13 changed files with 5775 additions and 0 deletions

60
b3dm/b3dm_api.py Normal file
View File

@ -0,0 +1,60 @@
from sanic import Sanic, Request, json
from sanic_cors import CORS
import logging
import time
from earthwork_api import earthwork_bp
from terrain_api import terrain_bp
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 创建Sanic应用
app = Sanic("TerrainAnalysisAPI")
# 显式注册蓝图
app.blueprint(earthwork_bp)
app.blueprint(terrain_bp)
CORS(app, automatic_options=True)
# 中间件:请求计时
@app.middleware("request")
async def add_start_time(request: Request):
request.ctx.start_time = time.time()
@app.middleware("response")
async def add_response_time(request: Request, response):
if hasattr(request.ctx, "start_time"):
process_time = (time.time() - request.ctx.start_time) * 1000
response.headers["X-Process-Time"] = f"{process_time:.2f}ms"
@app.get("/api/v1/health")
async def health_check(request: Request):
"""健康检查"""
return json({
"status": "healthy",
"timestamp": time.time(),
"service": "terrain-analysis-api",
"version": "1.0.0"
})
# 错误处理
@app.exception(Exception)
async def handle_exception(request: Request, exception):
"""全局异常处理"""
logger.error(f"未处理的异常: {exception}")
return json({
"error": "服务器内部错误",
"message": str(exception) if app.debug else "请稍后重试",
"success": False
}, status=500)
if __name__ == "__main__":
# 启动服务器
app.run(
host="0.0.0.0",
port=8000,
debug=True, # 生产环境设为False
access_log=True,
auto_reload=True
)

View File

@ -0,0 +1,566 @@
from minio import Minio
from minio.error import S3Error
import json
import os
import numpy as np
from urllib.parse import urlparse
import hashlib
import time
import re
import pickle
from datetime import datetime
class MinIO3DTilesManager:
def __init__(self, endpoint_url, access_key, secret_key, secure=False,
mapping_file="minio_path_mapping.pkl"):
"""
初始化MinIO客户端
Args:
endpoint_url: MinIO服务地址 (: 222.212.85.86:9001)
access_key: 访问密钥
secret_key: 秘密密钥
secure: 是否使用HTTPS
mapping_file: 路径映射文件名
"""
if endpoint_url.startswith('http://'):
endpoint_url = endpoint_url.replace('http://', '')
elif endpoint_url.startswith('https://'):
endpoint_url = endpoint_url.replace('https://', '')
secure = True
self.endpoint_url = endpoint_url
self.access_key = access_key
self.secret_key = secret_key
self.minio_client = Minio(
endpoint_url,
access_key=access_key,
secret_key=secret_key,
secure=secure
)
# 获取脚本所在目录
self.script_dir = os.path.dirname(os.path.abspath(__file__))
# 映射文件路径
self.mapping_file = os.path.join(self.script_dir, mapping_file)
# 加载现有的路径映射
self.path_mapping = self.load_path_mapping()
def load_path_mapping(self):
"""加载路径映射数据"""
if os.path.exists(self.mapping_file):
try:
with open(self.mapping_file, 'rb') as f:
mapping = pickle.load(f)
return mapping
except Exception as e:
return {}
else:
return {}
def save_path_mapping(self):
"""保存路径映射数据"""
try:
with open(self.mapping_file, 'wb') as f:
pickle.dump(self.path_mapping, f)
return True
except Exception as e:
return False
def get_cache_key(self, tileset_url, save_dir=None):
"""生成缓存键"""
# 基于URL和保存目录生成缓存键
cache_data = f"{tileset_url}|{save_dir}"
return hashlib.md5(cache_data.encode()).hexdigest()
def get_cached_tileset_info(self, tileset_url, save_dir=None):
"""获取缓存的tileset信息"""
cache_key = self.get_cache_key(tileset_url, save_dir)
# 检查缓存映射中是否有这个tileset
for file_id, info in self.path_mapping.items():
if info.get('cache_key') == cache_key and info.get('is_tileset_root'):
# 检查入口文件是否存在
local_path = info.get('local_path')
if local_path and os.path.exists(local_path):
return local_path
return None
def update_tileset_cache(self, tileset_url, save_dir, local_path):
"""更新tileset缓存信息"""
cache_key = self.get_cache_key(tileset_url, save_dir)
# 将tileset根文件标记为缓存
entry_bucket, entry_path = self.parse_minio_url(tileset_url)
file_id = f"{entry_bucket}/{entry_path}"
if file_id in self.path_mapping:
self.path_mapping[file_id]['cache_key'] = cache_key
self.path_mapping[file_id]['is_tileset_root'] = True
self.path_mapping[file_id]['tileset_url'] = tileset_url
self.path_mapping[file_id]['save_dir'] = save_dir
self.path_mapping[file_id]['cache_time'] = datetime.now().isoformat()
def download_full_tileset(self, tileset_url, save_dir=None, region_filter=None, use_cache=True):
"""
下载完整的3D Tiles数据集支持缓存功能
Args:
tileset_url: MinIO上的tileset.json URL
save_dir: 本地保存目录
region_filter: 区域过滤器
use_cache: 是否使用缓存
Returns:
tuple: (success, result)
- success: True/False
- result: 如果success=True且use_cache=True返回本地路径否则返回True/False
"""
if save_dir is None:
save_dir = os.path.join(self.script_dir, "data_3dtiles")
# 清理保存目录名称
save_dir = self.clean_file_path(save_dir)
# 检查缓存:只需检查入口文件是否存在
if use_cache:
cached_path = self.get_cached_tileset_info(tileset_url, save_dir)
if cached_path:
# 入口文件存在,默认缓存完备
return True, cached_path
# 解析URL
entry_bucket, entry_path = self.parse_minio_url(tileset_url)
if not entry_bucket or not entry_path:
return False, "无法解析URL"
entry_dir = os.path.dirname(entry_path)
# 创建保存目录
os.makedirs(save_dir, exist_ok=True)
visited = set()
# 下载入口文件
entry_local_path = self.get_local_path(
entry_bucket, entry_path,
entry_bucket, entry_dir,
save_dir
)
success, result = self.download_file(entry_bucket, entry_path, entry_local_path)
if not success:
return False, f"入口文件下载失败: {result}"
entry_id = f"{entry_bucket}/{entry_path}"
visited.add(entry_id)
# 加载tileset数据
tileset_data = self.load_json_from_minio(entry_bucket, entry_path)
if not tileset_data or "root" not in tileset_data:
return False, "无效的tileset.json文件"
# 遍历下载所有文件
self.traverse_and_download_tileset(
tileset_data["root"],
entry_bucket,
entry_dir,
entry_bucket,
entry_dir,
save_dir,
region_filter,
None,
visited
)
# 更新缓存信息
self.update_tileset_cache(tileset_url, save_dir, entry_local_path)
# 保存路径映射
self.save_path_mapping()
if use_cache:
return True, entry_local_path
else:
return True, True
def get_tileset_local_path(self, tileset_url, save_dir=None):
"""
获取已缓存的tileset本地路径
Args:
tileset_url: tileset的URL
save_dir: 保存目录
Returns:
str: 本地路径如果未缓存则返回None
"""
if save_dir is None:
save_dir = os.path.join(self.script_dir, "data_3dtiles")
return self.get_cached_tileset_info(tileset_url, save_dir)
def clear_tileset_cache(self, tileset_url=None, save_dir=None):
"""
清除tileset缓存
Args:
tileset_url: 指定要清除的tileset URL如果为None则清除所有
save_dir: 保存目录
Returns:
bool: 成功/失败
"""
try:
if tileset_url:
# 清除指定tileset的缓存
cache_key = self.get_cache_key(tileset_url, save_dir)
# 找出所有相关的缓存条目
to_remove = []
for file_id, info in self.path_mapping.items():
if info.get('cache_key') == cache_key:
to_remove.append(file_id)
# 删除这些条目
for file_id in to_remove:
del self.path_mapping[file_id]
print(f"已清除tileset缓存: {tileset_url}")
else:
# 清除所有缓存
self.path_mapping = {}
if os.path.exists(self.mapping_file):
os.remove(self.mapping_file)
print("已清除所有缓存")
return True
except Exception as e:
return False
# 以下是原有的辅助方法
def clean_filename(self, filename):
"""清理文件名中的特殊字符"""
if not filename:
return ""
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1F]', '_', filename)
cleaned = re.sub(r'_+', '_', cleaned)
cleaned = cleaned.strip(' _')
return cleaned
def parse_minio_url(self, url):
"""解析MinIO URL"""
if url.startswith('http://') or url.startswith('https://'):
parsed = urlparse(url)
path = parsed.path.lstrip('/')
parts = path.split('/', 1)
if len(parts) == 2:
bucket, key = parts
else:
bucket = parts[0]
key = ""
return bucket, key
else:
parts = url.split('/', 1)
if len(parts) == 2:
bucket, key = parts
else:
bucket = parts[0]
key = ""
return bucket, key
def download_file(self, bucket_name, object_name, file_path):
"""从MinIO下载文件"""
try:
os.makedirs(os.path.dirname(file_path), exist_ok=True)
# 清理文件名
clean_file_path = self.clean_file_path(file_path)
# 检查是否已下载
file_id = f"{bucket_name}/{object_name}"
if file_id in self.path_mapping:
mapped_path = self.path_mapping[file_id]['local_path']
if os.path.exists(mapped_path):
return True, mapped_path
# 下载文件
self.minio_client.fget_object(
bucket_name,
object_name,
clean_file_path
)
# 更新路径映射
self.path_mapping[file_id] = {
'local_path': clean_file_path,
'bucket': bucket_name,
'object': object_name,
'download_time': datetime.now().isoformat(),
'size': os.path.getsize(clean_file_path)
}
return True, clean_file_path
except S3Error as e:
return False, str(e)
except Exception as e:
return False, str(e)
def clean_file_path(self, file_path):
"""清理文件路径中的所有特殊字符"""
dir_name = os.path.dirname(file_path)
file_name = os.path.basename(file_path)
if dir_name:
dir_parts = dir_name.split(os.sep)
cleaned_parts = []
for part in dir_parts:
cleaned_part = self.clean_filename(part)
if cleaned_part:
cleaned_parts.append(cleaned_part)
cleaned_dir = os.sep.join(cleaned_parts)
else:
cleaned_dir = ""
cleaned_file = self.clean_filename(file_name)
if cleaned_dir:
cleaned_path = os.path.join(cleaned_dir, cleaned_file)
else:
cleaned_path = cleaned_file
return cleaned_path
def load_json_from_minio(self, bucket_name, object_name):
"""从MinIO加载JSON文件"""
try:
self.minio_client.stat_object(bucket_name, object_name)
response = self.minio_client.get_object(bucket_name, object_name)
content = response.read().decode('utf-8')
response.close()
response.release_conn()
return json.loads(content)
except S3Error as e:
return None
except Exception as e:
return None
def get_local_path(self, bucket_name, object_name, base_bucket, base_object, save_dir):
"""生成保持目录结构的本地路径"""
clean_bucket = self.clean_filename(bucket_name)
bucket_dir = clean_bucket
if bucket_name == base_bucket and base_object:
base_dir = os.path.dirname(base_object)
if base_dir:
if object_name.startswith(base_dir):
relative_path = object_name[len(base_dir):].lstrip('/\\')
else:
relative_path = object_name
else:
relative_path = object_name
else:
relative_path = object_name
if relative_path:
path_parts = relative_path.split('/')
cleaned_parts = []
for part in path_parts:
cleaned_part = self.clean_filename(part)
if cleaned_part:
cleaned_parts.append(cleaned_part)
if cleaned_parts:
cleaned_relative = '/'.join(cleaned_parts)
local_path = os.path.join(save_dir, bucket_dir, cleaned_relative)
else:
local_path = os.path.join(save_dir, bucket_dir)
else:
local_path = os.path.join(save_dir, bucket_dir)
return os.path.normpath(local_path)
def traverse_and_download_tileset(self, tile_obj, current_bucket, current_dir,
base_bucket, base_dir, save_dir,
region_filter=None, parent_transform=None,
visited=None):
"""递归遍历并下载3D Tiles文件"""
if visited is None:
visited = set()
current_transform = parent_transform
if "transform" in tile_obj:
tile_mat = tile_obj["transform"]
if current_transform is None:
current_transform = tile_mat
else:
mat1 = np.array(current_transform).reshape(4, 4)
mat2 = np.array(tile_mat).reshape(4, 4)
combined_mat = np.dot(mat1, mat2).flatten().tolist()
current_transform = combined_mat
skip_current_tile = False
if region_filter and "boundingVolume" in tile_obj:
if not region_filter.check_tile_bounding_volume(tile_obj["boundingVolume"]):
skip_current_tile = True
if not skip_current_tile and "content" in tile_obj and "uri" in tile_obj["content"]:
tile_uri = tile_obj["content"]["uri"]
file_bucket = current_bucket
file_path = ""
if tile_uri.startswith('http://') or tile_uri.startswith('https://'):
parsed_bucket, parsed_path = self.parse_minio_url(tile_uri)
if parsed_bucket:
file_bucket = parsed_bucket
file_path = parsed_path
else:
if current_dir:
file_path = os.path.join(current_dir, tile_uri).replace('\\', '/')
else:
file_path = tile_uri
file_path = file_path.lstrip('/')
file_id = f"{file_bucket}/{file_path}"
if file_id not in visited:
print(f"下载文件:{file_id}")
visited.add(file_id)
local_path = self.get_local_path(
file_bucket, file_path,
base_bucket, base_dir,
save_dir
)
self.download_file(file_bucket, file_path, local_path)
if file_path.lower().endswith('.json'):
sub_tileset = self.load_json_from_minio(file_bucket, file_path)
if sub_tileset and "root" in sub_tileset:
sub_dir = os.path.dirname(file_path) if file_path else ""
self.traverse_and_download_tileset(
sub_tileset["root"],
file_bucket,
sub_dir,
base_bucket,
base_dir,
save_dir,
region_filter,
current_transform,
visited
)
if "children" in tile_obj:
for child_tile in tile_obj["children"]:
self.traverse_and_download_tileset(
child_tile,
current_bucket,
current_dir,
base_bucket,
base_dir,
save_dir,
region_filter,
current_transform,
visited
)
def upload_file(self, bucket_name, object_name, file_path):
"""上传文件到MinIO"""
try:
if not os.path.exists(file_path):
return False, f"文件不存在: {file_path}"
file_size = os.path.getsize(file_path)
self.minio_client.fput_object(bucket_name, object_name, file_path)
return True, f"{bucket_name}/{object_name}"
except S3Error as e:
return False, f"MinIO上传错误: {e}"
except Exception as e:
return False, f"上传失败: {str(e)}"
def upload_directory(self, bucket_name, local_dir, remote_prefix=""):
"""上传目录到MinIO"""
if not os.path.exists(local_dir):
return [], [f"目录不存在: {local_dir}"]
uploaded_files = []
failed_files = []
for root, dirs, files in os.walk(local_dir):
for file in files:
local_path = os.path.join(root, file)
rel_path = os.path.relpath(local_path, local_dir)
if remote_prefix:
remote_path = os.path.join(remote_prefix, rel_path).replace('\\', '/')
else:
remote_path = rel_path.replace('\\', '/')
success, message = self.upload_file(bucket_name, remote_path, local_path)
if success:
uploaded_files.append(remote_path)
else:
failed_files.append((remote_path, message))
return uploaded_files, failed_files
def check_and_create_bucket(self, bucket_name):
"""检查并创建bucket"""
try:
if not self.minio_client.bucket_exists(bucket_name):
self.minio_client.make_bucket(bucket_name)
return True, f"创建bucket: {bucket_name}"
return True, f"bucket已存在: {bucket_name}"
except S3Error as e:
return False, f"创建bucket失败: {e}"
# 使用示例
if __name__ == "__main__":
# 配置参数
ENDPOINT_URL = "222.212.85.86:9000"
ACCESS_KEY = "WuRenJi"
SECRET_KEY = "WRJ@2024"
# 初始化管理器
manager = MinIO3DTilesManager(
endpoint_url=ENDPOINT_URL,
access_key=ACCESS_KEY,
secret_key=SECRET_KEY,
secure=False
)
# 使用缓存下载tileset
tileset_url = "http://222.212.85.86:9000/300bdf2b-a150-406e-be63-d28bd29b409f/model/石棉0908/terra_b3dms/tileset.json"
# 第一次下载(会下载到本地)
success, result = manager.download_full_tileset(tileset_url, use_cache=True)
if success:
print(f"下载成功,本地路径: {result}")
# 第二次下载相同URL直接从缓存返回
success, result = manager.download_full_tileset(tileset_url, use_cache=True)
if success:
print(f"从缓存获取,本地路径: {result}")
# 强制重新下载(忽略缓存)
success, result = manager.download_full_tileset(tileset_url, use_cache=False)
if success:
print("强制重新下载成功")
# 获取缓存的本地路径
local_path = manager.get_tileset_local_path(tileset_url)
if local_path:
print(f"缓存的本地路径: {local_path}")

757
b3dm/data_3dtiles_to_dem.py Normal file
View File

@ -0,0 +1,757 @@
# data_3dtiles_to_dem52
import os
import json
import numpy as np
import pandas as pd
import pyproj
import struct
from osgeo import gdal, osr
import uuid
from glb_with_draco import DracoGLBParser
# 解决GDAL中文路径/警告问题(生产必加)
gdal.SetConfigOption("GDAL_FILENAME_IS_UTF8", "YES")
gdal.SetConfigOption("CPL_ZIP_ENCODING", "UTF-8")
gdal.PushErrorHandler('CPLQuietErrorHandler')
class RegionFilter:
"""完整的区域过滤器,支持所有格式"""
def __init__(self, region_coords=None, enable_tile_filter=True, debug=False):
"""
初始化区域过滤器
:param debug: 是否输出调试信息
"""
self.region_coords = region_coords
self.enable_tile_filter = enable_tile_filter
self.debug = debug
if region_coords:
# 提取区域边界
lons = [coord[0] for coord in region_coords]
lats = [coord[1] for coord in region_coords]
self.min_lon = min(lons)
self.max_lon = max(lons)
self.min_lat = min(lats)
self.max_lat = max(lats)
# 扩展边界(避免边缘误差)
self.expand_factor = 0.1
lon_expand = (self.max_lon - self.min_lon) * self.expand_factor
lat_expand = (self.max_lat - self.min_lat) * self.expand_factor
self.filter_min_lon = self.min_lon - lon_expand
self.filter_max_lon = self.max_lon + lon_expand
self.filter_min_lat = self.min_lat - lat_expand
self.filter_max_lat = self.max_lat + lat_expand
if self.debug:
print(f"[DEBUG] 区域过滤器初始化:")
print(f" 目标区域: Lon[{self.min_lon:.6f}, {self.max_lon:.6f}], "
f"Lat[{self.min_lat:.6f}, {self.max_lat:.6f}]")
print(f" 过滤区域: Lon[{self.filter_min_lon:.6f}, {self.filter_max_lon:.6f}], "
f"Lat[{self.filter_min_lat:.6f}, {self.filter_max_lat:.6f}]")
else:
self.min_lon = self.min_lat = -180
self.max_lon = self.max_lat = 180
self.filter_min_lon = self.filter_min_lat = -180
self.filter_max_lon = self.filter_max_lat = 180
if self.debug:
print("[DEBUG] 区域过滤器: 未指定区域,将处理所有数据")
def check_tile_bounding_volume(self, bounding_volume):
"""检查瓦片的包围体是否与指定区域相交"""
if not self.region_coords or not self.enable_tile_filter:
return True
try:
if 'region' in bounding_volume:
return self._check_region(bounding_volume['region'])
elif 'box' in bounding_volume:
box = bounding_volume['box']
if self.debug:
print(f"[DEBUG] 检查box包围体长度={len(box)}")
if len(box) == 12:
result = self._check_box_12(box)
elif len(box) == 15:
result = self._check_box_15(box)
else:
if self.debug:
print(f"[DEBUG] 异常box长度 {len(box)},默认通过")
return True
if self.debug and not result:
print(f"[DEBUG] Box被过滤")
return result
elif 'sphere' in bounding_volume:
return self._check_sphere(bounding_volume['sphere'])
return True
except Exception as e:
if self.debug:
print(f"[DEBUG] 包围体检查出错: {e}")
return True
def _check_region(self, region):
"""检查region格式 [west, south, east, north, minHeight, maxHeight]"""
if len(region) != 6:
return True
west, south, east, north, min_h, max_h = region
# 检查是否完全在过滤区域外
if (east < self.filter_min_lon or west > self.filter_max_lon or
north < self.filter_min_lat or south > self.filter_max_lat):
if self.debug:
print(f"[DEBUG] Region过滤: [{west:.3f},{south:.3f},{east:.3f},{north:.3f}]")
return False
return True
def _check_box_12(self, box):
"""检查12值box格式"""
# 提取参数
cx, cy, cz = box[0], box[1], box[2]
halfX = box[3]
halfY = box[7]
halfZ = box[11]
if self.debug:
print(f"[DEBUG] 12值box: center=({cx:.1f},{cy:.1f},{cz:.1f}), "
f"halfs=({halfX},{halfY},{halfZ})")
try:
# 获取转换器
transformer = self._get_transformer()
# 转换中心点
center_lon, center_lat, _ = transformer.transform(
cx, cy, cz, radians=False
)
# 计算最大偏移(简化方法,避免计算所有角点)
max_half = max(halfX, halfY, halfZ)
# 转换为经纬度偏移
earth_radius = 6378137.0
lon_offset = np.degrees(max_half / (earth_radius * np.cos(np.radians(center_lat))))
lat_offset = np.degrees(max_half / earth_radius)
# 计算box范围
box_min_lon = center_lon - lon_offset
box_max_lon = center_lon + lon_offset
box_min_lat = center_lat - lat_offset
box_max_lat = center_lat + lat_offset
if self.debug:
print(f"[DEBUG] 中心: ({center_lon:.6f}, {center_lat:.6f})")
print(f"[DEBUG] 范围: Lon[{box_min_lon:.6f}, {box_max_lon:.6f}], "
f"Lat[{box_min_lat:.6f}, {box_max_lat:.6f}]")
# 检查相交
if (box_max_lon < self.filter_min_lon or box_min_lon > self.filter_max_lon or
box_max_lat < self.filter_min_lat or box_min_lat > self.filter_max_lat):
return False
return True
except Exception as e:
if self.debug:
print(f"[DEBUG] Box12检查失败: {e}")
return True # 失败时默认通过
def _check_box_15(self, box):
"""检查标准15值box格式"""
if len(box) < 15:
return True
cx, cy, cz = box[0], box[1], box[2]
halfX, halfY, halfZ = box[12], box[13], box[14]
# 简化处理:只检查中心点
transformer = self._get_transformer()
center_lon, center_lat, _ = transformer.transform(cx, cy, cz, radians=False)
# 计算最大偏移
max_half = max(halfX, halfY, halfZ)
earth_radius = 6378137.0
lon_offset = np.degrees(max_half / (earth_radius * np.cos(np.radians(center_lat))))
lat_offset = np.degrees(max_half / earth_radius)
box_min_lon = center_lon - lon_offset
box_max_lon = center_lon + lon_offset
box_min_lat = center_lat - lat_offset
box_max_lat = center_lat + lat_offset
if (box_max_lon < self.filter_min_lon or box_min_lon > self.filter_max_lon or
box_max_lat < self.filter_min_lat or box_min_lat > self.filter_max_lat):
return False
return True
def _check_sphere(self, sphere):
"""检查sphere格式 [centerX, centerY, centerZ, radius]"""
if len(sphere) < 4:
return True
cx, cy, cz, radius = sphere[0], sphere[1], sphere[2], sphere[3]
transformer = self._get_transformer()
center_lon, center_lat, _ = transformer.transform(cx, cy, cz, radians=False)
# 计算半径对应的经纬度偏移
earth_radius = 6378137.0
lon_offset = np.degrees(radius / (earth_radius * np.cos(np.radians(center_lat))))
lat_offset = np.degrees(radius / earth_radius)
sphere_min_lon = center_lon - lon_offset
sphere_max_lon = center_lon + lon_offset
sphere_min_lat = center_lat - lat_offset
sphere_max_lat = center_lat + lat_offset
if (sphere_max_lon < self.filter_min_lon or sphere_min_lon > self.filter_max_lon or
sphere_max_lat < self.filter_min_lat or sphere_min_lat > self.filter_max_lat):
return False
return True
def _get_transformer(self):
"""获取或创建坐标转换器"""
if not hasattr(self, '_transformer'):
ecef = pyproj.Proj(proj='geocent', ellps='WGS84', datum='WGS84')
lla = pyproj.Proj(proj='latlong', ellps='WGS84', datum='WGS84')
self._transformer = pyproj.Transformer.from_proj(ecef, lla)
return self._transformer
def filter_points(self, points):
"""
过滤点集只保留区域内的点
:param points: 点列表或numpy数组每行 [lon, lat, height]
:return: 过滤后的点列表
"""
if not self.region_coords or len(points) == 0:
return points
# 转换为numpy数组处理
if isinstance(points, list):
points_array = np.array(points)
else:
points_array = points
if len(points_array) == 0 or points_array.shape[1] < 2:
return points_array.tolist() if isinstance(points, list) else points_array
# 检查每个点是否在区域内
in_region_mask = (
(points_array[:, 0] >= self.min_lon) &
(points_array[:, 0] <= self.max_lon) &
(points_array[:, 1] >= self.min_lat) &
(points_array[:, 1] <= self.max_lat)
)
filtered_points = points_array[in_region_mask]
print(f"区域过滤: {len(points_array)} 个点 -> {len(filtered_points)} 个点 "
f"(过滤掉 {len(points_array) - len(filtered_points)} 个点)")
return filtered_points.tolist() if isinstance(points, list) else filtered_points
# ========== 核心工具函数:矩阵变换 ==========
def apply_transform_matrix(vertices, transform_matrix):
"""
将模型的局部相对顶点坐标通过transform矩阵转换为绝对ECEF坐标
:param vertices: 原始局部顶点 (n,3) numpy数组
:param transform_matrix: 瓦片的transform矩阵 一维列表/数组长度164x4矩阵
:return: 绝对ECEF坐标 (n,3) numpy数组
"""
if transform_matrix is None or len(transform_matrix) != 16:
return vertices
# reshape为4x4然后转置因为glTF是列主序
mat = np.array(transform_matrix).reshape(4, 4).astype(np.float64).T
# 顶点齐次坐标化 (n,3) -> (n,4) 最后一列补1
ones = np.ones((vertices.shape[0], 1), dtype=np.float64)
vertices_hom = np.hstack([vertices, ones])
# 矩阵乘法:顶点坐标 * 变换矩阵 = 绝对坐标
vertices_ecef_hom = np.dot(vertices_hom, mat.T)
# 还原为三维坐标 (n,4) -> (n,3)
vertices_ecef = vertices_ecef_hom[:, :3]
return vertices_ecef
def parse_b3dm_to_points(b3dm_path, region_filter=None, transform_matrix=None):
"""
解析B3DM文件,提取顶点的经纬度+高程
关键修改区域过滤移到读取顶点后进行
"""
# 获取脚本所在目录
script_dir = os.path.dirname(os.path.abspath(__file__))
temp_dir = os.path.join(script_dir, "temp_glb")
# 创建临时目录(如果不存在)
os.makedirs(temp_dir, exist_ok=True)
# 1. 读取B3DM二进制文件
with open(b3dm_path, "rb") as f:
b3dm_data = f.read()
# 跳过头部
header = struct.unpack('<4sIIIIII', b3dm_data[:28])
ft_json_len, ft_bin_len, bt_json_len, bt_bin_len = header[3:7]
offset = 28
offset += ft_json_len # 跳过Feature Table JSON
offset += ft_bin_len # 跳过Feature Table Binary
offset += bt_json_len # 跳过Batch Table JSON
offset += bt_bin_len # 跳过Batch Table Binary
# 提取glb数据
glb_data = b3dm_data[offset:]
if len(glb_data) < 12:
return []
# 2. 使用脚本目录下的临时文件
temp_file_path = None
try:
# 生成唯一临时文件名
temp_filename = f"temp_{uuid.uuid4().hex[:8]}.glb"
temp_file_path = os.path.join(temp_dir, temp_filename)
# 将GLB数据写入临时文件
with open(temp_file_path, "wb") as tmp_glb:
tmp_glb.write(glb_data)
# 使用DracoGLBParser解析
parser = DracoGLBParser(temp_file_path)
# 解析 GLB 结构
parser.parse_glb_structure()
# 分析结构
parser.analyze_structure()
# 解码 Draco 网格
mesh = parser.decode_draco_meshes()
except Exception as e:
print(f"读取GLB数据失败 {b3dm_path}: {e}")
return []
finally:
# 清理临时文件
if temp_file_path and os.path.exists(temp_file_path):
try:
os.unlink(temp_file_path)
except Exception as e:
pass
if mesh is None:
print(f"无法加载模型: {b3dm_path}")
return []
# 获取顶点数据
vertices = parser.get_all_vertices()
if vertices.size == 0 or len(vertices.shape) < 2 or vertices.shape[1] != 3:
print(f"顶点数据格式无效: {b3dm_path}")
return []
# 3. 应用transform矩阵局部坐标 → 绝对ECEF坐标
vertices = apply_transform_matrix(vertices, transform_matrix)
# 4. ECEF坐标转WGS84经纬度+高程
try:
ecef = pyproj.Proj(proj='geocent', ellps='WGS84', datum='WGS84')
lla = pyproj.Proj(proj='latlong', ellps='WGS84', datum='WGS84')
transformer = pyproj.Transformer.from_proj(ecef, lla, always_xy=True)
lons, lats, heights = transformer.transform(
vertices[:, 0], vertices[:, 1], vertices[:, 2], radians=False
)
# 组合成点集
points = np.column_stack([lons, lats, heights])
# 5. 基本数据清洗(过滤异常值)
# 过滤nan/inf
valid_mask = np.isfinite(points).all(axis=1)
points = points[valid_mask]
if len(points) == 0:
print(f"B3DM文件 {os.path.basename(b3dm_path)} 转换后无有效点")
return []
# 过滤经纬度超限值
geo_mask = (
(points[:, 0] >= -180) & (points[:, 0] <= 180) &
(points[:, 1] >= -90) & (points[:, 1] <= 90)
)
points = points[geo_mask]
if len(points) == 0:
print(f"B3DM文件 {os.path.basename(b3dm_path)} 经纬度超限")
return []
print(f"{os.path.basename(b3dm_path)} 提取到 {len(points)} 个原始顶点")
# 6. 【关键修改】应用区域过滤器(如果提供)
# 在读取顶点后进行区域过滤,而不是在瓦片级别过滤
if region_filter:
points = region_filter.filter_points(points)
if len(points) == 0:
print(f"B3DM文件 {os.path.basename(b3dm_path)} 的所有点都在区域外,跳过")
return []
print(f"最终保留 {len(points)} 个在区域内的顶点")
return points.tolist()
except Exception as e:
print(f"坐标转换失败 {b3dm_path}: {e}")
return []
def traverse_nested_tiles(tile_obj, base_dir, b3dm_paths, tile_transforms, region_filter=None, parent_transform=None):
"""
深度递归遍历瓦片,自动识别子JSONB3DM
修改移除瓦片级别的区域过滤完全依赖顶点级别的过滤
"""
# 1. 计算当前瓦片的最终变换矩阵
current_transform = parent_transform
if "transform" in tile_obj:
tile_mat = tile_obj["transform"]
if current_transform is None:
current_transform = tile_mat
else:
# 合并变换矩阵
mat1 = np.array(current_transform).reshape(4, 4)
mat2 = np.array(tile_mat).reshape(4, 4)
combined_mat = np.dot(mat1, mat2).flatten().tolist()
current_transform = combined_mat
# 2. 【修改】不再检查瓦片包围体,直接处理内容
# 这样可以避免因粗略的包围体判断而漏掉部分在区域内的顶点
# 3. 处理当前瓦片的内容
if "content" in tile_obj and "uri" in tile_obj["content"]:
tile_uri = tile_obj["content"]["uri"]
tile_abs_path = os.path.join(base_dir, tile_uri)
if tile_uri.lower().endswith(".json"):
# 情况1:uri是子JSON文件 → 递归解析这个子JSON
if os.path.exists(tile_abs_path):
print(f"解析嵌套子JSON文件: {tile_abs_path}")
with open(tile_abs_path, "r", encoding="utf-8") as f:
sub_tileset = json.load(f)
sub_base_dir = os.path.dirname(tile_abs_path)
traverse_nested_tiles(sub_tileset["root"], sub_base_dir, b3dm_paths, tile_transforms, region_filter, current_transform)
else:
print(f"嵌套子JSON文件不存在,跳过: {tile_abs_path}")
elif tile_uri.lower().endswith(".b3dm"):
# 情况2:uri是B3DM文件 → 收集路径+对应transform矩阵
if os.path.exists(tile_abs_path):
b3dm_paths.append(tile_abs_path)
tile_transforms.append(current_transform)
print(f"收集到B3DM文件: {tile_abs_path}")
else:
print(f"B3DM文件不存在,跳过: {tile_abs_path}")
# 4. 递归遍历当前tile的子节点children
if "children" in tile_obj and len(tile_obj["children"]) > 0:
for child_tile in tile_obj["children"]:
traverse_nested_tiles(child_tile, base_dir, b3dm_paths, tile_transforms, region_filter, current_transform)
def parse_tileset(tileset_path, region_coords=None):
"""重构主解析函数,支持区域过滤+矩阵变换"""
if not os.path.exists(tileset_path):
raise FileNotFoundError(f"根tileset.json文件不存在: {tileset_path}")
# 初始化区域过滤器(将在顶点级别使用)
region_filter = RegionFilter(region_coords)
# 读取根tileset.json
with open(tileset_path, "r", encoding="utf-8") as f:
tileset_json = json.load(f)
root_dir = os.path.dirname(tileset_path)
b3dm_paths = []
tile_transforms = []
print(f"开始遍历tileset结构...")
# 调用深度递归函数
traverse_nested_tiles(tileset_json["root"], root_dir, b3dm_paths, tile_transforms, region_filter, None)
print(f"\n遍历完成,共发现 {len(b3dm_paths)} 个B3DM文件:")
for i, b3dm_path in enumerate(b3dm_paths):
print(f" {i+1}. {os.path.basename(b3dm_path)}")
# 批量解析所有B3DM文件,合并点云
all_points = []
if len(b3dm_paths) == 0:
print("未提取到任何有效的B3DM文件")
return all_points
print(f"\n===== 开始解析B3DM文件 =====")
for i, (b3dm_path, transform_mat) in enumerate(zip(b3dm_paths, tile_transforms), 1):
print(f"解析文件 {i}/{len(b3dm_paths)}: {os.path.basename(b3dm_path)}")
points = parse_b3dm_to_points(b3dm_path, region_filter, transform_mat)
if points:
all_points.extend(points)
print(f" 提取到 {len(points)} 个点")
else:
print(f" 未提取到有效点")
# 点云去重+优化
if all_points:
all_points = np.array(all_points)
original_count = len(all_points)
all_points = np.unique(all_points.round(decimals=6), axis=0)
print(f"\n最终提取点云数量: {len(all_points)} 个 (已去重, 去除了 {original_count - len(all_points)} 个重复点)")
# ========== 新增:输出整个地图文件的经纬度高程范围 ==========
print("\n" + "=" * 60)
print("地图文件总体范围统计:")
print("-" * 60)
# 计算经纬度范围
min_lon = np.min(all_points[:, 0])
max_lon = np.max(all_points[:, 0])
min_lat = np.min(all_points[:, 1])
max_lat = np.max(all_points[:, 1])
min_height = np.min(all_points[:, 2])
max_height = np.max(all_points[:, 2])
avg_height = np.mean(all_points[:, 2])
std_height = np.std(all_points[:, 2])
# 计算中心点
center_lon = (min_lon + max_lon) / 2
center_lat = (min_lat + max_lat) / 2
center_height = (min_height + max_height) / 2
print(f"经度范围: {min_lon:.6f}° ~ {max_lon:.6f}° (跨度: {max_lon - min_lon:.6f}°)")
print(f"纬度范围: {min_lat:.6f}° ~ {max_lat:.6f}° (跨度: {max_lat - min_lat:.6f}°)")
print(f"高程范围: {min_height:.2f}m ~ {max_height:.2f}m (总高差: {max_height - min_height:.2f}m)")
print(f"平均高程: {avg_height:.2f}m (±{std_height:.2f}m)")
print(f"\n中心点坐标:")
print(f" 位置: ({center_lon:.6f}°, {center_lat:.6f}°)")
print(f" 高程: {center_height:.2f}m")
# 输出边界坐标(用于复制使用)
print(f"\n边界坐标:")
print(f" 西北角: ({min_lon:.6f}, {max_lat:.6f})")
print(f" 东北角: ({max_lon:.6f}, {max_lat:.6f})")
print(f" 西南角: ({min_lon:.6f}, {min_lat:.6f})")
print(f" 东南角: ({max_lon:.6f}, {min_lat:.6f})")
# 如果有区域过滤,显示过滤效果
if region_coords:
region_min_lon = min(coord[0] for coord in region_coords)
region_max_lon = max(coord[0] for coord in region_coords)
region_min_lat = min(coord[1] for coord in region_coords)
region_max_lat = max(coord[1] for coord in region_coords)
print(f"\n区域过滤效果:")
print(f" 原始地图范围: Lon[{region_min_lon:.6f}, {region_max_lon:.6f}], Lat[{region_min_lat:.6f}, {region_max_lat:.6f}]")
print(f" 提取数据范围: Lon[{min_lon:.6f}, {max_lon:.6f}], Lat[{min_lat:.6f}, {max_lat:.6f}]")
# 计算覆盖率
region_width = region_max_lon - region_min_lon
region_height = region_max_lat - region_min_lat
extracted_width = max_lon - min_lon
extracted_height = max_lat - min_lat
width_coverage = extracted_width / region_width * 100 if region_width > 0 else 0
height_coverage = extracted_height / region_height * 100 if region_height > 0 else 0
print(f" 经度方向覆盖率: {width_coverage:.1f}%")
print(f" 纬度方向覆盖率: {height_coverage:.1f}%")
print("=" * 60)
return all_points
def points_to_dem(points, output_dem_path, pixel_size=0.0001):
"""将离散点云插值为DEMGeoTIFF格式- 使用Scipy插值优化版本"""
if len(points) == 0:
raise ValueError("无有效点云数据,无法生成DEM")
# 转换为numpy数组
points_array = np.array(points)
lons = points_array[:, 0]
lats = points_array[:, 1]
heights = points_array[:, 2]
min_lon, max_lon = lons.min(), lons.max()
min_lat, max_lat = lats.min(), lats.max()
print(f"DEM范围: Lon[{min_lon:.6f}, {max_lon:.6f}], Lat[{min_lat:.6f}, {max_lat:.6f}]")
print(f"点云数量: {len(points)}")
print(f"高程范围: {heights.min():.2f} ~ {heights.max():.2f}")
# 计算网格尺寸
width = int((max_lon - min_lon) / pixel_size) + 1
height = int((max_lat - min_lat) / pixel_size) + 1
# 限制网格大小,避免过大
max_grid_size = 5000 # 最大网格尺寸
if width > max_grid_size or height > max_grid_size:
print(f"警告: 网格尺寸过大 ({width}x{height}),自动调整像素大小...")
# 重新计算像素大小
larger_dim = max(width, height)
pixel_size = pixel_size * (larger_dim / max_grid_size)
width = int((max_lon - min_lon) / pixel_size) + 1
height = int((max_lat - min_lat) / pixel_size) + 1
print(f"调整后像素大小: {pixel_size:.6f}°")
print(f"DEM网格: {width}x{height} (像素大小: {pixel_size:.6f}°)")
# 创建网格坐标
x_grid = np.linspace(min_lon, max_lon, width)
y_grid = np.linspace(max_lat, min_lat, height) # 纬度从上到下递减
xi, yi = np.meshgrid(x_grid, y_grid)
# 使用scipy进行插值
from scipy.interpolate import griddata
print("开始插值计算...")
# 方法1: 先尝试线性插值
try:
zi = griddata((lons, lats), heights, (xi, yi), method='linear')
nan_count = np.isnan(zi).sum()
nan_percent = nan_count / (width * height) * 100
print(f"线性插值完成,空白区域: {nan_count} 像素 ({nan_percent:.1f}%)")
# 如果有空白区域,使用最近邻方法填充
if nan_count > 0:
print("使用最近邻方法填充空白区域...")
zi_nn = griddata((lons, lats), heights, (xi, yi), method='nearest')
mask = np.isnan(zi)
zi[mask] = zi_nn[mask]
# 再次检查
nan_count = np.isnan(zi).sum()
if nan_count > 0:
print(f"仍有 {nan_count} 个空白像素,填充为最低高程值")
min_height = heights.min()
zi[np.isnan(zi)] = min_height
except Exception as e:
print(f"线性插值失败: {e}")
print("尝试使用最近邻插值...")
zi = griddata((lons, lats), heights, (xi, yi), method='nearest')
# 创建GeoTIFF
print("创建GeoTIFF文件...")
driver = gdal.GetDriverByName("GTiff")
dem_ds = driver.Create(
output_dem_path, width, height, 1, gdal.GDT_Float32,
options=["COMPRESS=LZW", "TILED=YES", "PREDICTOR=2", "ZLEVEL=9"]
)
if dem_ds is None:
raise RuntimeError(f"无法创建DEM文件: {output_dem_path}")
# 设置投影和地理变换
srs = osr.SpatialReference()
srs.ImportFromEPSG(4326) # WGS84
dem_ds.SetProjection(srs.ExportToWkt())
geotransform = [
min_lon, pixel_size, 0,
max_lat, 0, -pixel_size
]
dem_ds.SetGeoTransform(geotransform)
# 写入数据
band = dem_ds.GetRasterBand(1)
band.WriteArray(zi)
band.SetNoDataValue(-9999.0)
band.SetDescription("Elevation")
band.SetUnitType("meters")
# 计算统计信息
print("计算统计信息...")
band.FlushCache()
band.ComputeStatistics(False)
# 设置颜色表(可选)
try:
import matplotlib.pyplot as plt
cmap = plt.cm.terrain
colors = cmap(np.linspace(0, 1, 256))
colors = (colors[:, :3] * 255).astype(np.uint8)
color_table = gdal.ColorTable()
for i in range(256):
color_table.SetColorEntry(i, (colors[i, 0], colors[i, 1], colors[i, 2], 255))
band.SetColorTable(color_table)
band.SetColorInterpretation(gdal.GCI_PaletteIndex)
except:
pass # 如果设置颜色表失败,继续
dem_ds = None # 关闭文件
print(f"DEM生成成功: {output_dem_path}")
print(f" 文件大小: {os.path.getsize(output_dem_path) / (1024*1024):.2f} MB")
# 验证文件
if os.path.exists(output_dem_path):
ds = gdal.Open(output_dem_path, gdal.GA_ReadOnly)
if ds:
band = ds.GetRasterBand(1)
stats = band.GetStatistics(True, True)
print(f" DEM统计: 最小值={stats[0]:.2f}m, 最大值={stats[1]:.2f}m")
print(f" 平均值={stats[2]:.2f}m, 标准差={stats[3]:.2f}m")
ds = None
else:
print("警告: DEM文件可能未成功生成")
def generate_dem(REGION_COORDS=None, tileset_path=None, dem_path=None):
# 配置参数
script_dir = os.path.dirname(os.path.abspath(__file__))
if tileset_path:
TILESET_PATH = tileset_path
else:
TILESET_PATH = os.path.dirname(script_dir) + "/data/3dtiles/tileset.json"
if dem_path :
OUTPUT_DEM_PATH = dem_path
else :
OUTPUT_DEM_PATH = os.path.join(script_dir, f"o_dem_{uuid.uuid4().hex[:8]}.tif")
PIXEL_SIZE = 0.0001
# 执行流程
print("=" * 60)
print("开始解析3D Tiles...")
if REGION_COORDS:
print(f"启用区域过滤: {REGION_COORDS}")
else:
print("未启用区域过滤,将处理所有数据")
points = parse_tileset(TILESET_PATH, REGION_COORDS)
print(f"解析完成,共提取点云: {len(points)}")
if len(points) > 0:
points_to_dem(points, OUTPUT_DEM_PATH, pixel_size=PIXEL_SIZE)
return OUTPUT_DEM_PATH
else:
print("无点云数据,无法生成DEM")
return None
if __name__ == "__main__":
# 测试示例
# 石棉县核心区域
# SHIMIAN_CORE = [(100.22476304, 29.38340151), (110.32476304, 31.28340151)]
SHIMIAN_CORE = [(100.22476304, 29.18340151), (110.32476304, 31.28340151)]
# 可以根据需要启用或禁用区域过滤
REGION_COORDS = SHIMIAN_CORE # 启用区域过滤
# REGION_COORDS = None # 禁用区域过滤
dem_path = generate_dem(REGION_COORDS)
if dem_path:
print(f"\nDEM文件已生成: {dem_path}")

377
b3dm/earthwork_api.py Normal file
View File

@ -0,0 +1,377 @@
# pip install fastapi uvicorn pdal pyvista numpy
from sanic import Blueprint, Request, json
from sanic.response import text
from typing import Dict, Any
import logging
from earthwork_calculator_point_cloud import EarthworkCalculatorPointCloud
# 导入计算模块
from earthwork_calculator_3d_tiles import EarthworkCalculator3dTiles, AlgorithmType, EarthworkResult3dTiles
from tileset_data_source import TilesetDataSource
earthwork_bp = Blueprint("earthwork", url_prefix="")
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 全局变量
_calculator_point_cloud = None
_calculator_3d_tiles = None
_data_source_3d_tiles = None
# 初始化函数
async def init_app():
"""初始化应用"""
global _data_source_3d_tiles, _calculator_3d_tiles, _calculator_point_cloud
try:
# 配置数据源
tileset_path = "./data/3dtiles/tileset.json"
# 初始化数据源
_data_source_3d_tiles = TilesetDataSource(tileset_path)
await _data_source_3d_tiles.initialize()
# 初始化计算器-3dTiles
_calculator_3d_tiles = EarthworkCalculator3dTiles(_data_source_3d_tiles)
# 初始化计算器-点云
point_cloud_path = "./data/pointCloud/simulated_points.laz"
_calculator_point_cloud = EarthworkCalculatorPointCloud(point_cloud_path)
logger.info("土方量计算器初始化完成")
except ImportError as e:
logger.error(f"依赖库缺失: {str(e)}")
raise
except Exception as e:
logger.error(f"初始化失败: {str(e)}")
raise
# 土方量计算接口-3dTiles
@earthwork_bp.post("/api/v1/calc/earthwork3dTiles")
async def calc_earthwork(request: Request):
"""
土方量计算接口
请求参数示例:
{
"polygonCoords": [[120.1, 30.1], [120.2, 30.1], [120.2, 30.2], [120.1, 30.2]],
"designElevation": 50.0,
"algorithm": "tin",
"resolution": 1.0,
"crs": "EPSG:4326",
"interpolationMethod": "linear"
}
"""
try:
# 1. 接收前端传参
data = request.json
if not data:
return _error_response("请求参数不能为空", 400)
# 2. 提取参数
polygon_coords = data.get("polygonCoords")
design_elevation = data.get("designElevation")
if not polygon_coords:
return _error_response("多边形坐标不能为空", 400)
if design_elevation is None:
return _error_response("设计高程不能为空", 400)
# 3. 可选参数
algorithm = data.get("algorithm", "tin")
resolution = data.get("resolution", 1.0)
crs = data.get("crs", "EPSG:4326")
interpolation_method = data.get("interpolationMethod", "linear")
# 4. 参数验证
if len(polygon_coords) < 3:
return _error_response("多边形至少需要3个点", 400)
# 检查多边形是否闭合,如不闭合则自动闭合
if polygon_coords[0] != polygon_coords[-1]:
polygon_coords.append(polygon_coords[0])
# 算法验证
if algorithm not in ["grid", "tin", "prism"]:
return _error_response("算法必须是 grid, tin 或 prism", 400)
# 分辨率验证
if resolution <= 0 or resolution > 100:
return _error_response("分辨率必须在0-100米之间", 400)
# 5. 确保计算器已初始化
if _calculator_3d_tiles is None:
await init_app()
# 6. 执行计算
algorithm_type = AlgorithmType(algorithm)
result = await _calculator_3d_tiles.calculate(
polygon_coords=polygon_coords,
design_elevation=design_elevation,
algorithm=algorithm_type,
resolution=resolution,
target_crs=crs,
interpolation_method=interpolation_method
)
# 7. 返回成功响应
return _success_response(result)
except ValueError as e:
logger.warning(f"参数验证失败: {str(e)}")
return _error_response(f"参数错误: {str(e)}", 400)
except Exception as e:
logger.error(f"计算失败: {str(e)}")
return _error_response(f"服务器内部错误: {str(e)}", 500)
# 验证接口
@earthwork_bp.post("/api/v1/calc/earthwork3dTiles/validate")
async def validate_earthwork(request: Request):
"""验证计算参数接口"""
try:
# 1. 接收前端传参
data = request.json
if not data:
return _error_response("请求参数不能为空", 400)
# 2. 提取参数
polygon_coords = data.get("polygonCoords")
if not polygon_coords:
return _error_response("多边形坐标不能为空", 400)
# 3. 参数验证
if len(polygon_coords) < 3:
return _error_response("多边形至少需要3个点", 400)
# 检查多边形是否闭合,如不闭合则自动闭合
if polygon_coords[0] != polygon_coords[-1]:
polygon_coords.append(polygon_coords[0])
# 4. 确保计算器已初始化
if _calculator_3d_tiles is None:
await init_app()
# 5. 执行验证
validation_result = await _calculator_3d_tiles.validate(polygon_coords)
# 6. 返回结果
return _success_response(validation_result)
except Exception as e:
logger.error(f"验证失败: {str(e)}")
return _error_response(f"验证失败: {str(e)}", 400)
# 获取算法列表接口
@earthwork_bp.get("/api/v1/calc/earthwork3dTiles/algorithms")
async def get_algorithms(request: Request):
"""获取支持的算法列表接口"""
try:
algorithms = [
{
"id": "grid",
"name": "格网法",
"description": "将计算区域划分为规则格网,通过插值计算每个格网的高程变化,适合平坦或规则地形",
"accuracy": "中等",
"performance": "快速",
"parameters": {
"resolution": {
"name": "格网分辨率",
"description": "格网大小(米),影响计算精度和性能",
"default": 1.0,
"range": [0.1, 10.0]
}
}
},
{
"id": "tin",
"name": "三角网法",
"description": "基于不规则三角网TIN构建地形表面计算每个三角形的体积变化适合复杂地形",
"accuracy": "",
"performance": "中等",
"parameters": {
"resolution": {
"name": "不适用",
"description": "三角网法不使用固定的分辨率参数",
"default": None
}
}
},
{
"id": "prism",
"name": "三棱柱法",
"description": "结合三角网和垂直棱柱的高精度算法,计算每个三棱柱的体积,精度最高",
"accuracy": "最高",
"performance": "较慢",
"parameters": {
"resolution": {
"name": "棱柱宽度",
"description": "棱柱宽度(米),影响计算精度",
"default": 1.0,
"range": [0.1, 5.0]
}
}
}
]
return _success_response(algorithms)
except Exception as e:
logger.error(f"获取算法列表失败: {str(e)}")
return _error_response(f"获取算法列表失败: {str(e)}", 500)
# 批量计算接口
@earthwork_bp.post("/api/v1/calc/earthwork3dTiles/batch")
async def batch_calc_earthwork(request: Request):
"""批量土方量计算接口"""
try:
# 1. 接收前端传参
data = request.json
if not data:
return _error_response("请求参数不能为空", 400)
calculations = data.get("calculations", [])
if not calculations:
return _error_response("计算任务列表不能为空", 400)
if len(calculations) > 100:
return _error_response("批量计算数量超过限制最多100个", 400)
# 2. 确保计算器已初始化
if _calculator_3d_tiles is None:
await init_app()
# 3. 执行批量计算
results = []
errors = []
for i, calc_data in enumerate(calculations):
try:
# 提取参数
polygon_coords = calc_data.get("polygonCoords")
design_elevation = calc_data.get("designElevation")
if not polygon_coords or design_elevation is None:
errors.append({
"index": i,
"error": "缺少必要参数"
})
continue
# 参数验证
if len(polygon_coords) < 3:
errors.append({
"index": i,
"error": "多边形至少需要3个点"
})
continue
# 检查多边形是否闭合
if polygon_coords[0] != polygon_coords[-1]:
polygon_coords.append(polygon_coords[0])
# 可选参数
algorithm = calc_data.get("algorithm", "tin")
resolution = calc_data.get("resolution", 1.0)
crs = calc_data.get("crs", "EPSG:4326")
interpolation_method = calc_data.get("interpolationMethod", "linear")
# 执行计算
algorithm_type = AlgorithmType(algorithm)
result = await _calculator_3d_tiles.calculate(
polygon_coords=polygon_coords,
design_elevation=design_elevation,
algorithm=algorithm_type,
resolution=resolution,
target_crs=crs,
interpolation_method=interpolation_method
)
results.append(result)
except Exception as e:
errors.append({
"index": i,
"error": str(e),
"polygon": polygon_coords if 'polygon_coords' in locals() else None
})
continue
# 4. 返回结果
batch_result = {
"results": results,
"errors": errors,
"summary": {
"total": len(calculations),
"success": len(results),
"failed": len(errors),
"successRate": f"{(len(results)/len(calculations)*100):.1f}%" if calculations else "0%"
}
}
message = f"批量计算完成,成功 {len(results)} 个,失败 {len(errors)}"
return _success_response(batch_result)
except Exception as e:
logger.error(f"批量计算失败: {str(e)}")
return _error_response(f"批量计算失败: {str(e)}", 500)
# 核心接口:土方量计算-点云
@earthwork_bp.post("/api/v1/calc/earthworkPointCloud")
async def calc_earthwork_point_cloud(request: Request):
try:
# 1. 接收前端传参
data = request.json
if not data:
return json({
"code": 400,
"msg": "请求参数不能为空",
"data": None
}, status=400)
polygon_coords = data.get("polygonCoords") # 计算区域多边形坐标
design_elev = data.get("designElevation") # 设计高程
crs = data.get("crs", "EPSG:4326") # 坐标系默认WGS84
# 2. 确保计算器已初始化
if _calculator_point_cloud is None:
await init_app()
result = _calculator_point_cloud.calculate_earthwork(polygon_coords=polygon_coords, design_elev=design_elev, crs=crs)
# 3. 处理结果
if not result["success"]:
return _error_response(result["error"], 400)
# 4. 格式化结果
formatted_result = _calculator_point_cloud.format_result(result)
# 5. 返回成功响应
return _success_response(formatted_result)
except Exception as e:
logger.error(f"服务器错误: {str(e)}")
return _error_response(f"服务器内部错误: {str(e)}", 500)
def _success_response(data: Dict[str, Any]) -> json:
"""成功响应"""
return json({
"code": 200,
"msg": "计算成功",
"data": data
})
def _error_response(message: str, status_code: int = 400) -> json:
"""错误响应"""
return json({
"code": status_code,
"msg": message,
"data": None
}, status=status_code)

View File

@ -0,0 +1,526 @@
# earthwork_calculator.py
import numpy as np
from pyproj import Transformer, CRS
from scipy.spatial import Delaunay
from dataclasses import dataclass
from typing import List, Dict, Optional, Tuple, Union
import logging
from enum import Enum
from abc import ABC, abstractmethod
import math
logger = logging.getLogger(__name__)
class AlgorithmType(Enum):
"""计算算法类型"""
GRID = "grid"
TIN = "tin"
PRISM = "prism"
@dataclass
class EarthworkResult3dTiles:
"""土方量计算结果"""
cut_volume: float # 挖方量 (m³)
fill_volume: float # 填方量 (m³)
net_volume: float # 净方量 (m³)
area: float # 计算区域面积 (m²)
avg_elevation: float # 平均高程
min_elevation: float # 最低高程
max_elevation: float # 最高高程
points_count: int # 使用的点数
bounding_box: Dict[str, List[float]] # 边界框
volume_accuracy: float # 计算精度
algorithm: str # 使用的算法
resolution: float # 计算分辨率
def to_dict(self) -> Dict:
"""转换为字典"""
return {
"volume": {
"cut": round(self.cut_volume, 3),
"fill": round(self.fill_volume, 3),
"net": round(self.net_volume, 3),
"unit": ""
},
"area": {
"value": round(self.area, 3),
"unit": ""
},
"elevation": {
"average": round(self.avg_elevation, 3),
"min": round(self.min_elevation, 3),
"max": round(self.max_elevation, 3),
"unit": "m"
},
"statistics": {
"points_count": self.points_count,
"accuracy": round(self.volume_accuracy, 3),
"algorithm": self.algorithm
},
"bounding_box": self.bounding_box,
"calculation_params": {
"resolution": self.resolution,
"accuracy": self.volume_accuracy
}
}
class TerrainDataSource(ABC):
"""地形数据源抽象类"""
@abstractmethod
async def get_points_in_polygon(self,
polygon_coords: List[List[float]],
z_range: Optional[Tuple[float, float]] = None) -> np.ndarray:
"""
获取多边形区域内的点云数据
Args:
polygon_coords: 多边形坐标 [[x1,y1], [x2,y2], ...]
z_range: 高程范围 (min_z, max_z)
Returns:
Nx3的numpy数组 [x, y, z]
"""
pass
@abstractmethod
async def get_data_bounds(self) -> Dict[str, List[float]]:
"""获取数据范围"""
pass
@abstractmethod
def get_crs(self) -> str:
"""获取数据坐标系"""
pass
class GeometryUtils:
"""几何计算工具类"""
@staticmethod
def calculate_polygon_area(polygon_coords: List[List[float]]) -> float:
"""计算多边形面积(平面面积)"""
polygon_np = np.array(polygon_coords)
x = polygon_np[:, 0]
y = polygon_np[:, 1]
return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
@staticmethod
def is_point_in_polygon(point: np.ndarray, polygon: np.ndarray) -> bool:
"""判断点是否在多边形内"""
from matplotlib.path import Path
path = Path(polygon)
return path.contains_point(point)
@staticmethod
def calculate_triangle_area(points: np.ndarray) -> float:
"""计算三角形面积"""
a = np.linalg.norm(points[0] - points[1])
b = np.linalg.norm(points[1] - points[2])
c = np.linalg.norm(points[2] - points[0])
s = (a + b + c) / 2
return np.sqrt(s * (s - a) * (s - b) * (s - c))
@staticmethod
def create_grid(polygon: np.ndarray, resolution: float) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""创建规则格网"""
x_min, y_min = polygon.min(axis=0)
x_max, y_max = polygon.max(axis=0)
# 扩展一个格网单元
x_min -= resolution
x_max += resolution
y_min -= resolution
y_max += resolution
x_grid = np.arange(x_min, x_max + resolution, resolution)
y_grid = np.arange(y_min, y_max + resolution, resolution)
xx, yy = np.meshgrid(x_grid, y_grid)
return xx, yy, x_grid, y_grid
@staticmethod
def interpolate_grid(xx: np.ndarray, yy: np.ndarray,
points: np.ndarray, method: str = 'linear') -> np.ndarray:
"""格网插值"""
from scipy.interpolate import LinearNDInterpolator, CloughTocher2DInterpolator
grid_points = np.column_stack([xx.ravel(), yy.ravel()])
if method == 'linear':
interpolator = LinearNDInterpolator(points[:, :2], points[:, 2])
elif method == 'cubic':
interpolator = CloughTocher2DInterpolator(points[:, :2], points[:, 2])
else:
raise ValueError(f"不支持的插值方法: {method}")
elevations = interpolator(grid_points)
return elevations.reshape(xx.shape)
class EarthworkCalculator3dTiles:
"""土方量计算器"""
def __init__(self, data_source: TerrainDataSource):
"""
初始化计算器
Args:
data_source: 地形数据源
"""
self.data_source = data_source
self._transformer_cache = {}
async def calculate(self,
polygon_coords: List[List[float]],
design_elevation: float,
algorithm: AlgorithmType = AlgorithmType.TIN,
resolution: float = 1.0,
target_crs: str = "EPSG:4326",
interpolation_method: str = 'linear') -> EarthworkResult3dTiles:
"""
计算土方量
Args:
polygon_coords: 多边形坐标
design_elevation: 设计高程
algorithm: 计算算法
resolution: 格网分辨率
target_crs: 目标坐标系
interpolation_method: 插值方法
Returns:
EarthworkResult: 计算结果
"""
try:
# 1. 获取数据
points = await self.data_source.get_points_in_polygon(polygon_coords)
if points.size == 0:
raise ValueError("区域内没有找到高程数据")
# 2. 坐标转换
points = await self._transform_coordinates(points, target_crs)
# 3. 执行计算
if algorithm == AlgorithmType.GRID:
result = await self._calculate_grid(points, polygon_coords,
design_elevation, resolution,
interpolation_method)
elif algorithm == AlgorithmType.TIN:
result = await self._calculate_tin(points, polygon_coords,
design_elevation)
elif algorithm == AlgorithmType.PRISM:
result = await self._calculate_prism(points, polygon_coords,
design_elevation, resolution)
else:
raise ValueError(f"不支持的算法: {algorithm}")
return result
except Exception as e:
logger.error(f"土方量计算失败: {str(e)}")
raise
async def _transform_coordinates(self, points: np.ndarray,
target_crs: str) -> np.ndarray:
"""坐标转换"""
source_crs = self.data_source.get_crs()
if source_crs == target_crs:
return points
cache_key = f"{source_crs}->{target_crs}"
if cache_key not in self._transformer_cache:
self._transformer_cache[cache_key] = Transformer.from_crs(
CRS.from_string(source_crs),
CRS.from_string(target_crs),
always_xy=True
)
transformer = self._transformer_cache[cache_key]
points_2d = transformer.transform(points[:, 0], points[:, 1])
return np.column_stack([points_2d[0], points_2d[1], points[:, 2]])
async def _calculate_grid(self, points: np.ndarray,
polygon_coords: List[List[float]],
design_elevation: float,
resolution: float,
interpolation_method: str) -> EarthworkResult3dTiles:
"""格网法计算"""
polygon_np = np.array(polygon_coords)
# 创建格网
xx, yy, x_grid, y_grid = GeometryUtils.create_grid(polygon_np, resolution)
# 插值
natural_elevations = GeometryUtils.interpolate_grid(xx, yy, points, interpolation_method)
# 初始化挖填量
cut_volume = 0.0
fill_volume = 0.0
total_area = 0.0
# 遍历每个格网单元
for i in range(len(x_grid) - 1):
for j in range(len(y_grid) - 1):
# 格网四个角点
cell_corners = np.array([
[x_grid[i], y_grid[j]],
[x_grid[i+1], y_grid[j]],
[x_grid[i+1], y_grid[j+1]],
[x_grid[i], y_grid[j+1]]
])
# 检查格网中心点是否在多边形内
cell_center = cell_corners.mean(axis=0)
if not GeometryUtils.is_point_in_polygon(cell_center, polygon_np):
continue
# 获取格网四个角点的高程
cell_elevations = [
natural_elevations[j, i],
natural_elevations[j, i+1],
natural_elevations[j+1, i+1],
natural_elevations[j+1, i]
]
# 检查是否有无效数据
if any(np.isnan(elev) for elev in cell_elevations):
continue
# 计算格网平均高程
avg_elevation = np.mean(cell_elevations)
cell_area = resolution * resolution
total_area += cell_area
# 计算挖填量
height_diff = design_elevation - avg_elevation
if height_diff > 0:
fill_volume += height_diff * cell_area
else:
cut_volume += abs(height_diff) * cell_area
# 计算统计信息
area = GeometryUtils.calculate_polygon_area(polygon_coords)
mask = ~np.isnan(natural_elevations)
valid_elevations = natural_elevations[mask]
return EarthworkResult3dTiles(
cut_volume=cut_volume,
fill_volume=fill_volume,
net_volume=cut_volume - fill_volume,
area=area,
avg_elevation=np.mean(valid_elevations) if valid_elevations.size > 0 else 0,
min_elevation=np.min(valid_elevations) if valid_elevations.size > 0 else 0,
max_elevation=np.max(valid_elevations) if valid_elevations.size > 0 else 0,
points_count=points.shape[0],
bounding_box={
"min": [x_grid[0], y_grid[0]],
"max": [x_grid[-1], y_grid[-1]]
},
volume_accuracy=self._calculate_accuracy(points, resolution),
algorithm=AlgorithmType.GRID.value,
resolution=resolution
).to_dict()
async def _calculate_tin(self, points: np.ndarray,
polygon_coords: List[List[float]],
design_elevation: float) -> EarthworkResult3dTiles:
"""三角网法计算"""
polygon_np = np.array(polygon_coords)
# 创建Delaunay三角网
triangulation = Delaunay(points[:, :2])
# 筛选多边形内的三角形
cut_volume = 0.0
fill_volume = 0.0
total_area = 0.0
for simplex in triangulation.simplices:
triangle_points = points[simplex]
triangle_center = triangle_points.mean(axis=0)[:2]
# 检查三角形中心是否在多边形内
if not GeometryUtils.is_point_in_polygon(triangle_center, polygon_np):
continue
# 计算三角形面积
area = GeometryUtils.calculate_triangle_area(triangle_points[:, :2])
total_area += area
# 计算平均高程(使用三个顶点的高程)
avg_elevation = triangle_points[:, 2].mean()
# 计算挖填量
height_diff = design_elevation - avg_elevation
if height_diff > 0:
fill_volume += height_diff * area
else:
cut_volume += abs(height_diff) * area
# 计算统计信息
area = GeometryUtils.calculate_polygon_area(polygon_coords)
return EarthworkResult3dTiles(
cut_volume=cut_volume,
fill_volume=fill_volume,
net_volume=cut_volume - fill_volume,
area=area,
avg_elevation=points[:, 2].mean(),
min_elevation=points[:, 2].min(),
max_elevation=points[:, 2].max(),
points_count=points.shape[0],
bounding_box={
"min": points.min(axis=0)[:2].tolist(),
"max": points.max(axis=0)[:2].tolist()
},
volume_accuracy=self._calculate_accuracy(points, 0),
algorithm=AlgorithmType.TIN.value,
resolution=0
).to_dict()
async def _calculate_prism(self, points: np.ndarray,
polygon_coords: List[List[float]],
design_elevation: float,
resolution: float) -> EarthworkResult3dTiles:
"""三棱柱法计算"""
# 先创建TIN
polygon_np = np.array(polygon_coords)
triangulation = Delaunay(points[:, :2])
cut_volume = 0.0
fill_volume = 0.0
total_area = 0.0
for simplex in triangulation.simplices:
triangle_points = points[simplex]
triangle_center = triangle_points.mean(axis=0)[:2]
if not GeometryUtils.is_point_in_polygon(triangle_center, polygon_np):
continue
# 计算三角形面积
area = GeometryUtils.calculate_triangle_area(triangle_points[:, :2])
total_area += area
# 对于每个三角形,计算三棱柱体积
# 这里简化处理,使用梯形公式
for i in range(3):
j = (i + 1) % 3
# 计算边的长度
edge_length = np.linalg.norm(triangle_points[i, :2] - triangle_points[j, :2])
# 计算边两端点的挖填高度
height_i = design_elevation - triangle_points[i, 2]
height_j = design_elevation - triangle_points[j, 2]
# 计算边的平均挖填高度
avg_height = (abs(height_i) + abs(height_j)) / 2
# 计算边的面积假设边宽度为resolution
edge_area = edge_length * resolution
if height_i > 0 or height_j > 0:
fill_volume += avg_height * edge_area
else:
cut_volume += avg_height * edge_area
area = GeometryUtils.calculate_polygon_area(polygon_coords)
return EarthworkResult3dTiles(
cut_volume=cut_volume,
fill_volume=fill_volume,
net_volume=cut_volume - fill_volume,
area=area,
avg_elevation=points[:, 2].mean(),
min_elevation=points[:, 2].min(),
max_elevation=points[:, 2].max(),
points_count=points.shape[0],
bounding_box={
"min": points.min(axis=0)[:2].tolist(),
"max": points.max(axis=0)[:2].tolist()
},
volume_accuracy=self._calculate_accuracy(points, resolution),
algorithm=AlgorithmType.PRISM.value,
resolution=resolution
).to_dict()
def _calculate_accuracy(self, points: np.ndarray, resolution: float) -> float:
"""计算精度评估"""
if points.shape[0] < 10:
return 0.5
# 基于点密度和分辨率估算精度
if points.shape[0] > 0:
# 计算点密度
bounds = points[:, :2]
area = (bounds[:, 0].max() - bounds[:, 0].min()) * \
(bounds[:, 1].max() - bounds[:, 1].min())
if area > 0:
point_density = points.shape[0] / area
if point_density > 100 and resolution <= 0.5: # 高密度,高分辨率
return 0.05
elif point_density > 10 and resolution <= 1.0:
return 0.1
elif point_density > 1:
return 0.2
return 0.3
async def validate(self, polygon_coords: List[List[float]]) -> Dict:
"""验证计算参数"""
try:
points = await self.data_source.get_points_in_polygon(polygon_coords)
validation_result = {
"polygon_valid": len(polygon_coords) >= 3,
"area": GeometryUtils.calculate_polygon_area(polygon_coords),
"points_available": points.size > 0,
"points_count": points.shape[0] if points.size > 0 else 0,
"data_quality": "good" if points.shape[0] > 100 else "poor",
"suggested_algorithm": self._suggest_algorithm(points),
"estimated_accuracy": self._calculate_accuracy(points, 1.0) if points.size > 0 else None
}
if points.size > 0:
validation_result.update({
"elevation_range": {
"min": float(points[:, 2].min()),
"max": float(points[:, 2].max()),
"avg": float(points[:, 2].mean())
},
"bounding_box": {
"min": points.min(axis=0).tolist(),
"max": points.max(axis=0).tolist()
}
})
return validation_result
except Exception as e:
logger.error(f"验证失败: {str(e)}")
return {
"polygon_valid": False,
"error": str(e)
}
def _suggest_algorithm(self, points: np.ndarray) -> str:
"""建议计算算法"""
if points.shape[0] < 100:
return AlgorithmType.TIN.value
# 计算点的规则性
bounds = points[:, :2]
x_range = bounds[:, 0].max() - bounds[:, 0].min()
y_range = bounds[:, 1].max() - bounds[:, 1].min()
# 如果点分布相对规则,建议使用格网法
if x_range > 0 and y_range > 0:
aspect_ratio = max(x_range, y_range) / min(x_range, y_range)
if aspect_ratio < 2: # 相对规则
return AlgorithmType.GRID.value
return AlgorithmType.TIN.value

View File

@ -0,0 +1,691 @@
# earthwork_calculator.py
import pdal
import pyvista as pv
import numpy as np
import json
from typing import List, Tuple, Dict, Any, Optional
from dataclasses import dataclass, field
from enum import Enum
import traceback
class EarthworkAlgorithm(Enum):
"""土方量计算算法枚举"""
GRID = "grid"
TIN = "tin"
PRISM = "prism"
@dataclass
class EarthworkResultPointCloud:
"""土方量计算结果"""
cut_volume: float # 挖方量 (m³)
fill_volume: float # 填方量 (m³)
net_volume: float # 净方量 (m³)
area: float # 计算区域面积 (m²)
avg_elevation: float # 平均高程
min_elevation: float # 最低高程
max_elevation: float # 最高高程
points_count: int # 使用的点数
triangle_count: int = 0 # 三角形数量
grid_count: int = 0 # 网格数量仅GRID算法使用
prism_count: int = 0 # 棱柱体数量仅PRISM算法使用
bounding_box: Dict[str, List[float]] = field(default_factory=dict) # 边界框
volume_accuracy: float = 0.95 # 计算精度
algorithm: str = "TIN三角网法" # 使用的算法
resolution: float = 1.0 # 计算分辨率
algorithm_params: Dict[str, Any] = field(default_factory=dict) # 算法参数
def to_dict(self) -> Dict:
"""转换为字典"""
result = {
"volume": {
"cut": round(self.cut_volume, 3),
"fill": round(self.fill_volume, 3),
"net": round(self.net_volume, 3),
"unit": ""
},
"area": {
"value": round(self.area, 3),
"unit": ""
},
"elevation": {
"average": round(self.avg_elevation, 3),
"min": round(self.min_elevation, 3),
"max": round(self.max_elevation, 3),
"unit": "m"
},
"statistics": {
"points_count": self.points_count,
"accuracy": round(self.volume_accuracy, 3),
"algorithm": self.algorithm
},
"bounding_box": self.bounding_box,
"calculation_params": {
"resolution": self.resolution,
"accuracy": self.volume_accuracy,
**self.algorithm_params
}
}
# 根据算法类型添加特定的统计信息
if self.algorithm.startswith("GRID"):
result["statistics"]["grid_count"] = self.grid_count
elif self.algorithm.startswith("TIN"):
result["statistics"]["triangle_count"] = self.triangle_count
elif self.algorithm.startswith("PRISM"):
result["statistics"]["prism_count"] = self.prism_count
return result
class EarthworkCalculatorPointCloud:
"""土方量计算核心类(支持多种算法)"""
def __init__(self, point_cloud_path: str = "./data/your_point_cloud.laz"):
"""
初始化土方量计算器
Args:
point_cloud_path: 点云数据文件路径
"""
self.point_cloud_path = point_cloud_path
def validate_inputs(self, polygon_coords: List[List[float]], design_elev: float) -> Tuple[bool, str]:
"""验证输入参数"""
if not polygon_coords or len(polygon_coords) < 3:
return False, "多边形坐标至少需要3个点"
try:
design_elev = float(design_elev)
except (TypeError, ValueError):
return False, "设计高程必须是有效数字"
return True, ""
def create_polygon_string(self, polygon_coords: List[List[float]]) -> str:
"""创建PDAL多边形字符串"""
coords_list = []
for coord in polygon_coords:
if len(coord) >= 2:
coords_list.append(f"{coord[0]} {coord[1]}")
# 确保多边形闭合
if coords_list and coords_list[0] != coords_list[-1]:
coords_list.append(coords_list[0])
return "POLYGON((" + ", ".join(coords_list) + "))"
def calculate_bounding_box(self, points: np.ndarray) -> Dict[str, List[float]]:
"""
计算边界框
Args:
points: 点云坐标数组
Returns:
Dict: 边界框信息
"""
if len(points) == 0:
return {"min": [0, 0, 0], "max": [0, 0, 0]}
min_vals = np.min(points, axis=0)
max_vals = np.max(points, axis=0)
return {
"min": [float(min_vals[0]), float(min_vals[1]), float(min_vals[2])],
"max": [float(max_vals[0]), float(max_vals[1]), float(max_vals[2])]
}
def clip_point_cloud(self, polygon_coords: List[List[float]], crs: str = "EPSG:4326") -> pv.PolyData:
"""
裁剪点云数据
Args:
polygon_coords: 多边形坐标列表
crs: 坐标系
Returns:
pyvista.PolyData: 裁剪后的点云数据
"""
polygon_str = self.create_polygon_string(polygon_coords)
# PDAL管道配置
pipeline_config = {
"pipeline": [
{
"type": "readers.las",
"filename": self.point_cloud_path,
"spatialreference": crs
},
{
"type": "filters.crop",
"polygon": polygon_str
}
]
}
# 执行PDAL管道
pipeline = pdal.Pipeline(json.dumps(pipeline_config))
try:
pipeline.execute()
if len(pipeline.arrays) == 0:
raise ValueError("多边形区域内没有找到点云数据")
# 获取裁剪后的点云数据
points = pipeline.arrays[0]
x = points["X"]
y = points["Y"]
z = points["Z"]
return pv.PolyData(np.column_stack((x, y, z)))
except RuntimeError as e:
print(f"PDAL执行失败: {str(e)}")
# 如果没有PDAL数据生成模拟数据用于测试
return self.generate_mock_point_cloud(polygon_coords)
def generate_mock_point_cloud(self, polygon_coords: List[List[float]]) -> pv.PolyData:
"""
生成模拟点云数据仅用于测试
Args:
polygon_coords: 多边形坐标列表
Returns:
pyvista.PolyData: 模拟点云数据
"""
print("使用模拟数据进行测试...")
n_points = 1000
# 获取多边形边界
x_coords = [c[0] for c in polygon_coords]
y_coords = [c[1] for c in polygon_coords]
x_min, x_max = min(x_coords), max(x_coords)
y_min, y_max = min(y_coords), max(y_coords)
# 生成随机点
x = np.random.uniform(x_min, x_max, n_points)
y = np.random.uniform(y_min, y_max, n_points)
z = np.random.uniform(100, 120, n_points) # 模拟高程在100-120米之间
return pv.PolyData(np.column_stack((x, y, z)))
def create_tin_mesh(self, point_cloud: pv.PolyData) -> pv.PolyData:
"""
创建三角网TIN算法使用
Args:
point_cloud: 点云数据
Returns:
pyvista.PolyData: 三角网格
"""
if len(point_cloud.points) < 3:
raise ValueError("点云数据不足,无法构网")
try:
return point_cloud.delaunay_2d()
except Exception as e:
raise ValueError(f"三角网构网失败: {str(e)}")
def calculate_volumes_by_tin(self, mesh: pv.PolyData, design_elev: float) -> Dict[str, Any]:
"""
TIN算法计算土方量
Args:
mesh: 三角网格
design_elev: 设计高程
Returns:
Dict: 包含体积计算结果和额外信息
"""
points = mesh.points
elev_diff = points[:, 2] - design_elev
cut_volume = 0.0
fill_volume = 0.0
triangle_count = 0
# 遍历所有三角形面片计算体积
cells = mesh.cells.reshape(-1, 4)
if len(cells) == 0:
raise ValueError("无法生成有效的三角网")
for cell in cells:
if cell[0] == 3: # 三角形VTK格式3个顶点
triangle_count += 1
vertex_indices = cell[1:]
pts = points[vertex_indices]
# 计算三角形面积
v1 = pts[1] - pts[0]
v2 = pts[2] - pts[0]
area = 0.5 * np.linalg.norm(np.cross(v1, v2))
# 计算平均高程差
avg_diff = np.mean(elev_diff[vertex_indices])
vol = area * avg_diff
if vol > 0:
cut_volume += vol
else:
fill_volume += abs(vol)
return {
"cut_volume": cut_volume,
"fill_volume": fill_volume,
"net_volume": cut_volume - fill_volume,
"triangle_count": triangle_count
}
def calculate_volumes_by_grid(self, point_cloud: pv.PolyData, design_elev: float,
grid_size: float = 1.0) -> Dict[str, Any]:
"""
GRID算法计算土方量
Args:
point_cloud: 点云数据
design_elev: 设计高程
grid_size: 网格尺寸
Returns:
Dict: 包含体积计算结果和额外信息
"""
points = point_cloud.points
if len(points) == 0:
raise ValueError("点云数据为空")
# 计算边界
x_min, y_min = np.min(points[:, :2], axis=0)
x_max, y_max = np.max(points[:, :2], axis=0)
# 创建网格
x_edges = np.arange(x_min, x_max + grid_size, grid_size)
y_edges = np.arange(y_min, y_max + grid_size, grid_size)
grid_count = (len(x_edges) - 1) * (len(y_edges) - 1)
cut_volume = 0.0
fill_volume = 0.0
# 对每个网格计算土方量
for i in range(len(x_edges) - 1):
for j in range(len(y_edges) - 1):
# 获取当前网格内的点
mask = (points[:, 0] >= x_edges[i]) & (points[:, 0] < x_edges[i+1]) & \
(points[:, 1] >= y_edges[j]) & (points[:, 1] < y_edges[j+1])
grid_points = points[mask]
if len(grid_points) > 0:
# 计算网格内点的平均高程
avg_elevation = np.mean(grid_points[:, 2])
# 计算高程差
elev_diff = avg_elevation - design_elev
# 计算体积
cell_area = grid_size * grid_size
vol = cell_area * elev_diff
if vol > 0:
cut_volume += vol
else:
fill_volume += abs(vol)
return {
"cut_volume": cut_volume,
"fill_volume": fill_volume,
"net_volume": cut_volume - fill_volume,
"grid_count": grid_count
}
def calculate_volumes_by_prism(self, point_cloud: pv.PolyData, design_elev: float,
influence_radius: float = 0.5) -> Dict[str, Any]:
"""
PRISM算法计算土方量
Args:
point_cloud: 点云数据
design_elev: 设计高程
influence_radius: 影响半径
Returns:
Dict: 包含体积计算结果和额外信息
"""
points = point_cloud.points
if len(points) == 0:
raise ValueError("点云数据为空")
cut_volume = 0.0
fill_volume = 0.0
# 每个点的影响面积
influence_area = np.pi * influence_radius ** 2
prism_count = len(points)
for point in points:
# 计算高程差
elev_diff = point[2] - design_elev
# 计算体积
vol = influence_area * elev_diff
if vol > 0:
cut_volume += vol
else:
fill_volume += abs(vol)
return {
"cut_volume": cut_volume,
"fill_volume": fill_volume,
"net_volume": cut_volume - fill_volume,
"prism_count": prism_count
}
def calculate_statistics(self, point_cloud: pv.PolyData, mesh: pv.PolyData = None) -> Dict[str, float]:
"""
计算统计数据
Args:
point_cloud: 点云数据
mesh: 三角网格仅TIN算法需要
Returns:
Dict: 统计结果
"""
elevations = point_cloud.points[:, 2]
stats = {
"area": 0.0,
"max_elevation": np.max(elevations) if len(elevations) > 0 else 0.0,
"min_elevation": np.min(elevations) if len(elevations) > 0 else 0.0,
"avg_elevation": np.mean(elevations) if len(elevations) > 0 else 0.0,
"points_count": len(point_cloud.points)
}
# 计算面积
if mesh is not None:
stats["area"] = mesh.area
else:
# 对于非TIN算法使用多边形面积近似
if len(point_cloud.points) > 0:
# 使用点云的凸包面积
try:
hull = point_cloud.delaunay_2d()
stats["area"] = hull.area
except:
# 如果无法计算凸包,使用边界框面积
x_min, x_max = np.min(point_cloud.points[:, 0]), np.max(point_cloud.points[:, 0])
y_min, y_max = np.min(point_cloud.points[:, 1]), np.max(point_cloud.points[:, 1])
stats["area"] = (x_max - x_min) * (y_max - y_min)
return stats
def calculate_earthwork(self,
polygon_coords: List[List[float]],
design_elev: float,
algorithm: str = EarthworkAlgorithm.TIN.value,
algorithm_params: Optional[Dict[str, Any]] = None,
crs: str = "EPSG:4326",
volume_accuracy: Optional[float] = None,
resolution: Optional[float] = None) -> EarthworkResultPointCloud:
"""
主计算方法执行完整的土方量计算流程
Args:
polygon_coords: 多边形坐标列表
design_elev: 设计高程
algorithm: 计算算法可选值'grid', 'tin', 'prism'
algorithm_params: 算法特定参数
crs: 坐标系
volume_accuracy: 计算精度0-1之间
resolution: 计算分辨率
Returns:
EarthworkResultPointCloud: 计算结果
"""
try:
# 1. 验证输入
is_valid, message = self.validate_inputs(polygon_coords, design_elev)
if not is_valid:
raise ValueError(message)
design_elev = float(design_elev)
# 2. 验证算法参数
if algorithm not in [a.value for a in EarthworkAlgorithm]:
raise ValueError(f"不支持的算法: {algorithm}。支持的算法: {[a.value for a in EarthworkAlgorithm]}")
# 3. 设置默认参数
if algorithm_params is None:
algorithm_params = {}
# 4. 裁剪点云
point_cloud = self.clip_point_cloud(polygon_coords, crs)
# 5. 根据算法选择计算方法
algorithm_name = ""
mesh = None
if algorithm == EarthworkAlgorithm.TIN.value:
algorithm_name = "TIN三角网法"
mesh = self.create_tin_mesh(point_cloud)
volumes = self.calculate_volumes_by_tin(mesh, design_elev)
algorithm_params = {
"grid_size": algorithm_params.get("grid_size", 1.0)
}
elif algorithm == EarthworkAlgorithm.GRID.value:
algorithm_name = "GRID格网法"
grid_size = algorithm_params.get("grid_size", 1.0)
algorithm_name = f"GRID格网法(网格尺寸={grid_size}m)"
volumes = self.calculate_volumes_by_grid(point_cloud, design_elev, grid_size)
algorithm_params = {
"grid_size": grid_size
}
elif algorithm == EarthworkAlgorithm.PRISM.value:
algorithm_name = "PRISM棱柱体法"
influence_radius = algorithm_params.get("influence_radius", 0.5)
algorithm_name = f"PRISM棱柱体法(影响半径={influence_radius}m)"
volumes = self.calculate_volumes_by_prism(point_cloud, design_elev, influence_radius)
algorithm_params = {
"influence_radius": influence_radius
}
# 6. 计算统计数据
stats = self.calculate_statistics(point_cloud, mesh)
# 7. 计算边界框
bounding_box = self.calculate_bounding_box(point_cloud.points)
# 8. 计算或使用传入的精度和分辨率
if volume_accuracy is None:
# 根据算法和点云密度自动估算精度
volume_accuracy = self.estimate_accuracy(algorithm, point_cloud)
if resolution is None:
# 根据点云密度自动估算分辨率
resolution = self.estimate_resolution(point_cloud)
# 9. 创建EarthworkResultPointCloud对象
result = EarthworkResultPointCloud(
cut_volume=volumes["cut_volume"],
fill_volume=volumes["fill_volume"],
net_volume=volumes["net_volume"],
area=stats["area"],
avg_elevation=stats["avg_elevation"],
min_elevation=stats["min_elevation"],
max_elevation=stats["max_elevation"],
points_count=stats["points_count"],
triangle_count=volumes.get("triangle_count", 0),
grid_count=volumes.get("grid_count", 0),
prism_count=volumes.get("prism_count", 0),
bounding_box=bounding_box,
volume_accuracy=volume_accuracy,
algorithm=algorithm_name,
resolution=resolution,
algorithm_params=algorithm_params
)
return result
except Exception as e:
print(f"计算错误: {str(e)}")
print(traceback.format_exc())
raise
def estimate_accuracy(self, algorithm: str, point_cloud: pv.PolyData) -> float:
"""根据算法和点云密度估计计算精度"""
point_density = len(point_cloud.points) / max(point_cloud.area, 0.1)
# 基础精度
base_accuracy = {
EarthworkAlgorithm.TIN.value: 0.95,
EarthworkAlgorithm.GRID.value: 0.90,
EarthworkAlgorithm.PRISM.value: 0.85
}.get(algorithm, 0.90)
# 根据点云密度调整精度
if point_density > 10: # 高密度点云
accuracy_boost = min(0.05, point_density * 0.002)
elif point_density < 1: # 低密度点云
accuracy_boost = -0.05
else:
accuracy_boost = 0
estimated_accuracy = base_accuracy + accuracy_boost
# 确保精度在合理范围内
return max(0.7, min(0.99, estimated_accuracy))
def estimate_resolution(self, point_cloud: pv.PolyData) -> float:
"""根据点云密度估计分辨率"""
if len(point_cloud.points) < 2:
return 1.0
area = point_cloud.area
if area > 0:
point_count = len(point_cloud.points)
avg_spacing = np.sqrt(area / point_count)
return float(round(avg_spacing, 2))
return 1.0
def get_algorithm_info(self) -> Dict[str, Any]:
"""
获取支持的算法信息
Returns:
Dict: 算法信息
"""
return {
"supported_algorithms": [
{
"id": EarthworkAlgorithm.TIN.value,
"name": "TIN三角网法",
"description": "通过构建不规则三角网计算土方量,精度高,适合复杂地形",
"parameters": [
{
"name": "grid_size",
"type": "float",
"default": 1.0,
"description": "网格尺寸(m),用于点云预处理",
"required": False
}
]
},
{
"id": EarthworkAlgorithm.GRID.value,
"name": "GRID格网法",
"description": "将区域划分为规则网格计算土方量,计算速度快,适合大规模区域",
"parameters": [
{
"name": "grid_size",
"type": "float",
"default": 1.0,
"description": "网格尺寸(m)",
"required": True,
"min": 0.1,
"max": 10.0
}
]
},
{
"id": EarthworkAlgorithm.PRISM.value,
"name": "PRISM棱柱体法",
"description": "将每个点视为一个棱柱体计算土方量,计算简单快速",
"parameters": [
{
"name": "influence_radius",
"type": "float",
"default": 0.5,
"description": "点的影响半径(m)",
"required": True,
"min": 0.1,
"max": 5.0
}
]
}
],
"default_algorithm": EarthworkAlgorithm.TIN.value
}
# 使用示例
if __name__ == "__main__":
# 创建计算器实例
calculator = EarthworkCalculatorPointCloud("./data/sample_point_cloud.laz")
# 获取支持的算法信息
algorithm_info = calculator.get_algorithm_info()
print("支持的算法:")
for algo in algorithm_info["supported_algorithms"]:
print(f" {algo['id']}: {algo['name']} - {algo['description']}")
# 定义多边形区域
polygon = [
[116.3974, 39.9093],
[116.4084, 39.9093],
[116.4084, 39.9193],
[116.3974, 39.9193]
]
# 设计高程
design_elevation = 100.0
# 测试不同算法
algorithms = [
(EarthworkAlgorithm.TIN.value, {}, "TIN算法"),
(EarthworkAlgorithm.GRID.value, {"grid_size": 2.0}, "GRID算法(2米网格)"),
(EarthworkAlgorithm.PRISM.value, {"influence_radius": 1.0}, "PRISM算法(1米影响半径)")
]
for algo_id, params, description in algorithms:
print(f"\n使用{description}计算:")
try:
result = calculator.calculate_earthwork(
polygon_coords=polygon,
design_elev=design_elevation,
algorithm=algo_id,
algorithm_params=params
)
print(f" 挖方量: {result.cut_volume:.3f}")
print(f" 填方量: {result.fill_volume:.3f}")
print(f" 净方量: {result.net_volume:.3f}")
print(f" 计算面积: {result.area:.3f}")
print(f" 计算精度: {result.volume_accuracy:.3%}")
print(f" 分辨率: {result.resolution:.2f} m")
except Exception as e:
print(f" 计算失败: {str(e)}")

469
b3dm/glb_with_draco.py Normal file
View File

@ -0,0 +1,469 @@
import json
import struct
import numpy as np
import DracoPy
class DracoGLBParser:
"""使用 DracoPy 解析包含 Draco 压缩的 GLB 文件"""
def __init__(self, glb_file_path):
self.glb_file_path = glb_file_path
self.json_data = None
self.binary_data = None
self.decoded_meshes = [] # 缓存解码后的网格数据
def parse_glb_structure(self):
"""解析 GLB 文件结构"""
with open(self.glb_file_path, 'rb') as f:
# 读取 GLB 头部
magic = f.read(4)
version = struct.unpack('<I', f.read(4))[0]
total_length = struct.unpack('<I', f.read(4))[0]
print("=" * 60)
print(f"GLB 文件分析:")
print(f" 文件类型: {magic.decode('utf-8')}")
print(f" 版本: {version}")
print(f" 总大小: {total_length:,} bytes")
# 读取 JSON chunk
json_length = struct.unpack('<I', f.read(4))[0]
json_type = f.read(4)
if json_type != b'JSON':
raise ValueError(f"期望 JSON chunk但得到: {json_type}")
self.json_data = json.loads(f.read(json_length).decode('utf-8'))
print(f" JSON 大小: {json_length:,} bytes")
# 读取 Binary chunk
if f.tell() < total_length:
bin_length = struct.unpack('<I', f.read(4))[0]
bin_type = f.read(4)
if bin_type != b'BIN\x00':
raise ValueError(f"期望 BIN chunk但得到: {bin_type}")
self.binary_data = f.read(bin_length)
print(f" 二进制数据大小: {bin_length:,} bytes")
print("=" * 60)
return self
def analyze_structure(self):
"""分析 GLB 结构"""
if not self.json_data:
self.parse_glb_structure()
print("\nGLB 结构分析:")
print("-" * 40)
# 基本信息
asset = self.json_data.get('asset', {})
print(f"生成器: {asset.get('generator', '未知')}")
print(f"glTF 版本: {asset.get('version', '未知')}")
# Draco 扩展
extensions_used = self.json_data.get('extensionsUsed', [])
extensions_required = self.json_data.get('extensionsRequired', [])
if 'KHR_draco_mesh_compression' in extensions_used:
print("✓ 使用 Draco 压缩")
if 'KHR_draco_mesh_compression' in extensions_required:
print("⚠ Draco 压缩是必需的")
# 网格信息
meshes = self.json_data.get('meshes', [])
print(f"\n网格数量: {len(meshes)}")
for i, mesh in enumerate(meshes):
print(f" 网格 {i}: {mesh.get('name', '未命名')}")
primitives = mesh.get('primitives', [])
print(f" 图元数量: {len(primitives)}")
for j, primitive in enumerate(primitives):
print(f" 图元 {j}:")
if 'extensions' in primitive:
draco_info = primitive['extensions'].get('KHR_draco_mesh_compression')
if draco_info:
print(f" ✓ 使用 Draco 压缩")
print(f" 属性: {draco_info.get('attributes', {})}")
# 缓冲区信息
buffers = self.json_data.get('buffers', [])
buffer_views = self.json_data.get('bufferViews', [])
accessors = self.json_data.get('accessors', [])
print(f"\n缓冲区: {len(buffers)}")
print(f"BufferViews: {len(buffer_views)}")
print(f"访问器: {len(accessors)}")
return self
def decode_draco_meshes(self):
"""解码所有 Draco 压缩的网格"""
if not self.json_data:
self.parse_glb_structure()
meshes = []
buffer_views = self.json_data.get('bufferViews', [])
print("\n" + "=" * 60)
print("开始解码 Draco 压缩数据...")
print("=" * 60)
for mesh_idx, mesh in enumerate(self.json_data.get('meshes', [])):
mesh_name = mesh.get('name', f'mesh_{mesh_idx}')
for primitive_idx, primitive in enumerate(mesh.get('primitives', [])):
if 'extensions' in primitive:
draco_info = primitive['extensions'].get('KHR_draco_mesh_compression')
if draco_info:
print(f"\n解码: {mesh_name} - 图元 {primitive_idx}")
# 解码 Draco 数据
mesh_data = self._decode_primitive(draco_info, buffer_views)
if mesh_data:
meshes.append({
'mesh_idx': mesh_idx,
'primitive_idx': primitive_idx,
'name': mesh_name,
**mesh_data
})
self.decoded_meshes = meshes # 缓存解码结果
print("\n" + "=" * 60)
print(f"解码完成!共解码 {len(meshes)} 个网格")
print("=" * 60)
return meshes
def get_vertices(self, mesh_idx=0, primitive_idx=0):
"""
获取指定网格的顶点集合
参数:
mesh_idx: 网格索引默认0
primitive_idx: 图元索引默认0
返回:
np.array: 顶点数组形状为 (n, 3) None
"""
# 如果还没有解码数据,先解码
if not self.decoded_meshes:
self.decode_draco_meshes()
# 查找指定网格
for mesh in self.decoded_meshes:
if mesh['mesh_idx'] == mesh_idx and mesh['primitive_idx'] == primitive_idx:
return mesh.get('vertices')
print(f"未找到网格 {mesh_idx} 的图元 {primitive_idx}")
return None
def get_all_vertices(self):
"""
获取所有网格的所有顶点合并成一个数组
返回:
np.array: 所有顶点的合并数组形状为 (n, 3) None
"""
# 如果还没有解码数据,先解码
if not self.decoded_meshes:
self.decode_draco_meshes()
if not self.decoded_meshes:
print("没有解码的网格数据")
return None
# 收集所有顶点
all_vertices = []
for mesh in self.decoded_meshes:
if mesh.get('vertices') is not None:
all_vertices.append(mesh['vertices'])
if not all_vertices:
return None
# 合并所有顶点
return np.vstack(all_vertices)
def get_vertices_by_mesh_name(self, mesh_name):
"""
根据网格名称获取顶点集合
参数:
mesh_name: 网格名称
返回:
list: 包含所有匹配网格的顶点数组列表
"""
# 如果还没有解码数据,先解码
if not self.decoded_meshes:
self.decode_draco_meshes()
vertices_list = []
for mesh in self.decoded_meshes:
if mesh['name'] == mesh_name and mesh.get('vertices') is not None:
vertices_list.append(mesh['vertices'])
return vertices_list
def get_vertex_count(self):
"""
获取总顶点数
返回:
int: 所有网格的总顶点数
"""
vertices = self.get_all_vertices()
return len(vertices) if vertices is not None else 0
def _decode_primitive(self, draco_info, buffer_views):
"""解码单个图元的 Draco 数据"""
try:
# 获取 bufferView 信息
buffer_view_idx = draco_info['bufferView']
attributes = draco_info['attributes']
buffer_view = buffer_views[buffer_view_idx]
byte_offset = buffer_view.get('byteOffset', 0)
byte_length = buffer_view['byteLength']
print(f" BufferView: {buffer_view_idx}")
print(f" 属性映射: {attributes}")
print(f" 数据位置: offset={byte_offset}, length={byte_length}")
# 提取 Draco 压缩数据
draco_data = self.binary_data[byte_offset:byte_offset + byte_length]
print(f" Draco 数据大小: {len(draco_data):,} bytes")
# 使用 DracoPy 解码
print(" 正在使用 DracoPy 解码...")
draco_decoder = DracoPy.decode(draco_data)
# 解析解码结果
mesh_data = self._parse_draco_result(draco_decoder, attributes)
return mesh_data
except Exception as e:
print(f" 解码失败: {e}")
import traceback
traceback.print_exc()
return None
def _parse_draco_result(self, draco_decoder, attributes):
"""解析 DracoPy 解码结果"""
result = {
'vertices': None,
'faces': None,
'texcoords': None,
'batch_ids': None,
'normals': None,
'colors': None
}
# 获取顶点
if hasattr(draco_decoder, 'points'):
result['vertices'] = np.array(draco_decoder.points, dtype=np.float32)
print(f" 顶点数量: {len(result['vertices'])}")
# 获取面/三角形
if hasattr(draco_decoder, 'faces'):
faces_data = draco_decoder.faces
# 确保是三角形每面3个顶点
if len(faces_data) > 0:
if isinstance(faces_data[0], list) or isinstance(faces_data[0], tuple):
# 如果是列表的列表
result['faces'] = np.array(faces_data, dtype=np.uint32)
else:
# 如果是扁平化的数组
result['faces'] = np.array(faces_data, dtype=np.uint32).reshape(-1, 3)
print(f" 面数量: {len(result['faces']) if result['faces'] is not None else 0}")
# 获取属性数据
if hasattr(draco_decoder, 'attributes'):
attrs = draco_decoder.attributes
# 根据属性映射查找数据
for gltf_attr_name, draco_attr_id in attributes.items():
if draco_attr_id in attrs:
attr_data = attrs[draco_attr_id]
if gltf_attr_name == 'POSITION':
result['vertices'] = np.array(attr_data, dtype=np.float32)
elif gltf_attr_name == 'TEXCOORD_0':
result['texcoords'] = np.array(attr_data, dtype=np.float32)
elif gltf_attr_name == '_BATCHID':
result['batch_ids'] = np.array(attr_data, dtype=np.uint32)
elif gltf_attr_name == 'NORMAL':
result['normals'] = np.array(attr_data, dtype=np.float32)
elif gltf_attr_name == 'COLOR_0':
result['colors'] = np.array(attr_data, dtype=np.float32)
print(f" 已提取属性: {gltf_attr_name} (ID: {draco_attr_id})")
# 如果没有通过attributes获取到顶点尝试其他方式
if result['vertices'] is None and hasattr(draco_decoder, 'get_points'):
try:
result['vertices'] = np.array(draco_decoder.get_points(), dtype=np.float32)
except:
pass
return result
def save_decoded_meshes(self, meshes, output_format='obj'):
"""保存解码后的网格"""
import os
base_name = os.path.splitext(os.path.basename(self.glb_file_path))[0]
output_dir = f"{base_name}_decoded"
if not os.path.exists(output_dir):
os.makedirs(output_dir)
for mesh in meshes:
filename = f"{output_dir}/{mesh['name']}_p{mesh['primitive_idx']}.{output_format}"
if output_format == 'obj':
self._save_as_obj(mesh, filename)
elif output_format == 'ply':
self._save_as_ply(mesh, filename)
elif output_format == 'npz':
self._save_as_npz(mesh, filename)
else:
print(f"不支持的格式: {output_format}")
continue
print(f"已保存: {filename}")
def _save_as_obj(self, mesh, filename):
"""保存为 OBJ 格式"""
with open(filename, 'w') as f:
# 写入顶点
if mesh['vertices'] is not None:
for v in mesh['vertices']:
f.write(f"v {v[0]} {v[1]} {v[2]}\n")
# 写入纹理坐标
if mesh['texcoords'] is not None:
for uv in mesh['texcoords']:
f.write(f"vt {uv[0]} {uv[1]}\n")
# 写入法线
if mesh['normals'] is not None:
for n in mesh['normals']:
f.write(f"vn {n[0]} {n[1]} {n[2]}\n")
# 写入面
if mesh['faces'] is not None:
for face in mesh['faces']:
# OBJ 索引从1开始
face_indices = [str(idx + 1) for idx in face]
f.write(f"f {' '.join(face_indices)}\n")
def _save_as_ply(self, mesh, filename):
"""保存为 PLY 格式"""
from plyfile import PlyData, PlyElement
import numpy as np
vertices = mesh['vertices']
faces = mesh['faces']
if vertices is None:
return
# 创建顶点数据
vertex_data = np.zeros(len(vertices), dtype=[
('x', 'f4'), ('y', 'f4'), ('z', 'f4')
])
vertex_data['x'] = vertices[:, 0]
vertex_data['y'] = vertices[:, 1]
vertex_data['z'] = vertices[:, 2]
# 创建面数据
if faces is not None:
face_data = np.zeros(len(faces), dtype=[('vertex_indices', 'i4', (3,))])
face_data['vertex_indices'] = faces
# 写入文件
vertex_element = PlyElement.describe(vertex_data, 'vertex')
if faces is not None:
face_element = PlyElement.describe(face_data, 'face')
PlyData([vertex_element, face_element], text=False).write(filename)
else:
PlyData([vertex_element], text=False).write(filename)
def _save_as_npz(self, mesh, filename):
"""保存为 NPZ 格式"""
np.savez(
filename,
vertices=mesh['vertices'],
faces=mesh['faces'],
texcoords=mesh['texcoords'],
batch_ids=mesh['batch_ids'],
normals=mesh['normals'],
colors=mesh['colors']
)
# 使用示例
def main():
# 初始化解析器
parser = DracoGLBParser(r"D:\devForBdzlWork\ai_project_v1\b3dm\test\temp_glb\temp_6e895637.glb")
# 解析 GLB 结构
parser.parse_glb_structure()
# 分析结构
parser.analyze_structure()
# 解码 Draco 网格
meshes = parser.decode_draco_meshes()
# 使用新增的顶点获取方法
print("\n" + "=" * 60)
print("顶点获取方法演示:")
print("=" * 60)
# 1. 获取第一个网格的第一个图元的顶点
vertices = parser.get_vertices(mesh_idx=0, primitive_idx=0)
if vertices is not None:
print(f"1. 获取第一个网格顶点:")
print(f" 形状: {vertices.shape}")
print(f" 数据类型: {vertices.dtype}")
print(f" 前5个顶点: \n{vertices[:5]}")
# 2. 获取所有顶点(合并)
all_vertices = parser.get_all_vertices()
if all_vertices is not None:
print(f"\n2. 获取所有网格顶点(合并):")
print(f" 总顶点数: {len(all_vertices)}")
print(f" 形状: {all_vertices.shape}")
# 3. 获取总顶点数
total_vertices = parser.get_vertex_count()
print(f"\n3. 总顶点数: {total_vertices}")
# 4. 根据网格名称获取顶点
if meshes:
mesh_name = meshes[0]['name']
vertices_list = parser.get_vertices_by_mesh_name(mesh_name)
print(f"\n4. 根据名称 '{mesh_name}' 获取的顶点:")
for i, verts in enumerate(vertices_list):
print(f" 图元 {i}: {verts.shape if verts is not None else 'None'}")
# 保存解码后的网格
parser.save_decoded_meshes(meshes, output_format='obj')
if __name__ == "__main__":
main()

View File

@ -0,0 +1,380 @@
# terrain3d_analyzer_color13
import numpy as np
import matplotlib.pyplot as plt
from scipy.ndimage import sobel
import matplotlib as mpl
import rasterio
from osgeo import gdal
from matplotlib.patches import FancyArrowPatch
from mpl_toolkits.mplot3d import proj3d
import os
# 自定义3D箭头类
class Arrow3D(FancyArrowPatch):
def __init__(self, xs, ys, zs, *args, **kwargs):
super().__init__((0,0), (0,0), *args, **kwargs)
self._verts3d = xs, ys, zs
def do_3d_projection(self, renderer=None):
xs3d, ys3d, zs3d = self._verts3d
xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M)
self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
return min(zs)
def read_dem_rasterio(filepath):
"""使用rasterio读取DEM数据"""
try:
with rasterio.open(filepath) as src:
dem_data = src.read(1)
transform = src.transform
bounds = src.bounds
crs = src.crs
print(f"DEM信息:")
print(f" 尺寸: {dem_data.shape}")
print(f" 范围: {bounds}")
print(f" 坐标系: {crs}")
print(f" 高程范围: {np.nanmin(dem_data):.2f} - {np.nanmax(dem_data):.2f}")
if np.isnan(dem_data).any():
print(" 检测到NaN值")
dem_data = np.nan_to_num(dem_data, nan=np.nanmean(dem_data))
rows, cols = dem_data.shape
x = np.linspace(bounds.left, bounds.right, cols)
y = np.linspace(bounds.bottom, bounds.top, rows)
X, Y = np.meshgrid(x, y)
return X, Y, dem_data
except Exception as e:
print(f"使用rasterio读取失败: {e}")
return None
def read_slope_aspect_by_dem(dem_path, overall_3d_output_path=None) :
# ---------------------- 核心:解决中文乱码配置 ----------------------
setup_chinese_font()
# 尝试读取DEM数据
print("正在读取DEM文件...")
X, Y, Z = read_dem_rasterio(dem_path)
if X is None:
raise FileNotFoundError(f"无法读取DEM文件: {dem_path}")
# 检查数据有效性
if Z.size == 0 or np.all(Z == 0):
raise ValueError("DEM数据无效或全部为0")
# 重采样
if Z.shape[0] > 200 or Z.shape[1] > 200:
print(f"原始DEM尺寸较大 ({Z.shape}),进行重采样...")
from scipy.ndimage import zoom
scale_factor = min(200/Z.shape[0], 200/Z.shape[1])
Z = zoom(Z, scale_factor, order=1)
X = zoom(X, scale_factor, order=1)
Y = zoom(Y, scale_factor, order=1)
print(f"重采样后尺寸: {Z.shape}")
# ---------------------- 2. 计算坡度和坡向 ----------------------
print("计算坡度和坡向...")
dx_pixel = np.abs(X[0,1] - X[0,0]) * 111000
dy_pixel = np.abs(Y[1,0] - Y[0,0]) * 111000
dx = sobel(Z, axis=1) / (2 * dx_pixel)
dy = sobel(Z, axis=0) / (2 * dy_pixel)
slope_rad = np.arctan(np.sqrt(dx**2 + dy**2))
slope_deg = slope_rad * (180 / np.pi)
aspect_rad = np.arctan2(-dx, dy)
aspect_deg = aspect_rad * (180 / np.pi)
aspect_deg[aspect_deg < 0] += 360
print(f"坡度范围: {np.min(slope_deg):.2f}° - {np.max(slope_deg):.2f}°")
print(f"坡向范围: {np.min(aspect_deg):.2f}° - {np.max(aspect_deg):.2f}°")
# ---------------------- 3. 计算整体坡向 ----------------------
print("\n计算整体坡向...")
def calculate_overall_aspect(aspect_deg, slope_deg, method='weighted_mean'):
"""计算整体坡向"""
aspect_rad = np.deg2rad(aspect_deg)
if method == 'weighted_mean':
# 坡度加权平均法
u = np.sin(aspect_rad)
v = np.cos(aspect_rad)
weights = slope_deg.flatten()
weighted_u = np.nansum(u.flatten() * weights) / np.nansum(weights)
weighted_v = np.nansum(v.flatten() * weights) / np.nansum(weights)
weighted_aspect_rad = np.arctan2(weighted_u, weighted_v)
weighted_aspect_deg = np.rad2deg(weighted_aspect_rad)
if weighted_aspect_deg < 0:
weighted_aspect_deg += 360
weighted_strength = np.sqrt(weighted_u**2 + weighted_v**2)
return weighted_aspect_deg, weighted_strength, "坡度加权平均法"
elif method == 'vector_mean':
# 向量平均法
u = np.sin(aspect_rad)
v = np.cos(aspect_rad)
mean_u = np.nanmean(u)
mean_v = np.nanmean(v)
mean_aspect_rad = np.arctan2(mean_u, mean_v)
mean_aspect_deg = np.rad2deg(mean_aspect_rad)
if mean_aspect_deg < 0:
mean_aspect_deg += 360
vector_strength = np.sqrt(mean_u**2 + mean_v**2)
return mean_aspect_deg, vector_strength, "向量平均法"
# 使用坡度加权平均法计算整体坡向
overall_aspect, overall_strength, method_name = calculate_overall_aspect(aspect_deg, slope_deg, 'weighted_mean')
# 将整体坡向转换为方向描述
if overall_aspect < 22.5 or overall_aspect >= 337.5:
overall_direction = ""
elif 22.5 <= overall_aspect < 67.5:
overall_direction = "东北"
elif 67.5 <= overall_aspect < 112.5:
overall_direction = ""
elif 112.5 <= overall_aspect < 157.5:
overall_direction = "东南"
elif 157.5 <= overall_aspect < 202.5:
overall_direction = ""
elif 202.5 <= overall_aspect < 247.5:
overall_direction = "西南"
elif 247.5 <= overall_aspect < 292.5:
overall_direction = "西"
else:
overall_direction = "西北"
print(f"整体坡向 ({method_name}):")
print(f" 角度: {overall_aspect:.1f}°")
print(f" 方向: {overall_direction}")
# print(f" 一致性: {overall_strength:.3f}")
# ---------------------- 5. 3D可视化俯视图包含整体坡向和关键点坡向 ----------------------
print("\n生成3D俯视图可视化...")
fig3d, ax3d = plt.subplots(figsize=(16, 12), subplot_kw={"projection": "3d"})
# 绘制地形曲面 - 俯视图需要更清晰的地形表现
norm = mpl.colors.Normalize(vmin=np.percentile(slope_deg, 5),
vmax=np.percentile(slope_deg, 95))
plot_skip = max(2, Z.shape[0] // 60) # 增加采样密度,使俯视图更清晰
X_plot = X[::plot_skip, ::plot_skip]
Y_plot = Y[::plot_skip, ::plot_skip]
Z_plot = Z[::plot_skip, ::plot_skip]
slope_plot = slope_deg[::plot_skip, ::plot_skip]
surf = ax3d.plot_surface(
X_plot, Y_plot, Z_plot,
cmap="viridis_r",
alpha=0.85, # 增加透明度,使箭头更明显
linewidth=0.1, # 很细的网格线
facecolors=plt.cm.viridis_r(norm(slope_plot)),
zorder=1
)
# ---------------------- 绘制整体坡向箭头(中心位置,红色粗箭头) ----------------------
center_x = (X.min() + X.max()) / 2
center_y = (Y.min() + Y.max()) / 2
# 整体坡向箭头长度
arrow_length_overall = 0.15 * min(X.max()-X.min(), Y.max()-Y.min())
# 计算整体坡向箭头方向
scale_factor = 1.5
overall_aspect_rad = np.deg2rad(overall_aspect)
dx_overall = np.sin(overall_aspect_rad) * arrow_length_overall * scale_factor
dy_overall = np.cos(overall_aspect_rad) * arrow_length_overall * scale_factor
# 方案2如果希望箭头在地形上方一定高度
terrain_max_z = Z.max() # 地形最高点
float_height = 0.2 * terrain_max_z # 在地形最高点上方20%的高度
# 绘制整体坡向箭头(红色,粗)
arrow_overall = Arrow3D(
[center_x, center_x + dx_overall],
[center_y, center_y + dy_overall],
[terrain_max_z + float_height, terrain_max_z + float_height], # 在地形上方
mutation_scale=25, # 稍大一些的箭头
lw=5, # 更粗的线
arrowstyle='-|>',
color='red',
alpha=0.98, # 更高的透明度
zorder=20 # 更高的绘制顺序
)
ax3d.add_artist(arrow_overall)
# 在整体坡向箭头起点添加标记
ax3d.scatter(center_x, center_y, terrain_max_z + float_height,
color='red', s=120, edgecolor='white', linewidth=2, zorder=21)
# ---------------------- 整体坡向面板放置在左上角 ----------------------
# 计算左上角位置
panel_x = X.min() + 0.02 * (X.max() - X.min()) # 左边留2%的边距
panel_y = Y.max() - 0.02 * (Y.max() - Y.min()) # 上边留2%的边距
panel_z = Z.max() + 0.5 * (Z.max() - Z.min()) # 进一步提高Z坐标确保在视野内
# 整体坡向面板内容
overall_info = (
f"整体坡向分析\n"
f"角度: {overall_aspect:.1f}°\n"
f"方向: {overall_direction}"
# f"一致性: {overall_strength:.3f}"
)
# 绘制整体坡向面板(左上角)
ax3d.text(panel_x, panel_y, panel_z,
overall_info,
fontsize=12, fontweight='bold',
bbox=dict(facecolor='white', alpha=0.5, boxstyle="round,pad=0.5",
edgecolor='red', linewidth=2.5),
ha='left', va='top', zorder=30)
# ---------------------- 设置3D图参数 ----------------------
ax3d.set_xlabel("经度 (X)", fontsize=12)
ax3d.set_ylabel("纬度 (Y)", fontsize=12)
ax3d.set_zlabel("高程 (m)", fontsize=12)
# 获取文件名用于标题
filename = os.path.basename(dem_path)
ax3d.set_title(f"DEM三维俯视图 - 坡向分析\n文件: {filename}",
fontsize=14, fontweight='bold', pad=20)
# 添加颜色条
cbar = plt.colorbar(
mpl.cm.ScalarMappable(norm=norm, cmap='viridis_r'),
ax=ax3d, shrink=0.6, aspect=25, pad=0.15
)
cbar.set_label("坡度 (°)", fontsize=12)
# ---------------------- 设置为俯视图(高仰角) ----------------------
view_elev = 85 # 接近90度的仰角俯视效果
view_azim = overall_aspect + 180 # 从坡向的相反方向观看,可以看到坡面
# 确保方位角在0-360度范围内
view_azim = view_azim % 360
print(f"\n设置3D俯视图视角:")
print(f" 整体坡向: {overall_aspect:.1f}° ({overall_direction})")
print(f" 视角方位角: {view_azim:.1f}°")
print(f" 视角仰角: {view_elev:.1f}°")
# 应用俯视图视角设置
ax3d.view_init(elev=view_elev, azim=view_azim)
# 调整相机距离,使视角更广
ax3d.dist = 9.0 # 增加相机距离
# 设置坐标轴范围,确保所有元素都显示
z_min, z_max = Z.min(), Z.max()
z_padding = 0.6 * (z_max - z_min) # 适当增加Z轴范围
ax3d.set_zlim(z_min - 0.1*z_padding, z_max + z_padding)
# 设置XY轴范围
x_margin = 0.1 * (X.max() - X.min())
y_margin = 0.1 * (Y.max() - Y.min())
ax3d.set_xlim(X.min() - x_margin, X.max() + x_margin)
ax3d.set_ylim(Y.min() - y_margin, Y.max() + y_margin)
plt.tight_layout()
# 保存图片
if not overall_3d_output_path :
overall_3d_output_path = dem_path.replace('.tif', '_slopeAspect_3D_overlook.png')
plt.savefig(overall_3d_output_path, dpi=250, bbox_inches='tight', facecolor='white')
# plt.show()
os.remove(dem_path)
print(f"\n3D俯视图已保存: {overall_3d_output_path}")
print("分析完成!")
print("="*60)
return overall_3d_output_path
# ---------------------- 跨平台中文字体配置 ----------------------
def setup_chinese_font():
"""设置中文字体支持兼容Windows、Linux、macOS"""
import platform
plt.rcParams['axes.unicode_minus'] = False # 正确显示负号
system = platform.system()
# 尝试添加系统中文字体路径
font_paths = []
if system == 'Windows':
# Windows 字体路径
font_paths = [
'C:/Windows/Fonts/simhei.ttf', # 黑体
'C:/Windows/Fonts/simkai.ttf', # 楷体
'C:/Windows/Fonts/simsun.ttc', # 宋体
'C:/Windows/Fonts/microsoftyahei.ttf', # 微软雅黑
]
elif system == 'Darwin': # macOS
# macOS 字体路径
font_paths = [
'/System/Library/Fonts/PingFang.ttc', # 苹方
'/System/Library/Fonts/STHeiti Light.ttc', # 华文黑体
'/System/Library/Fonts/STHeiti Medium.ttc',
'/Library/Fonts/Arial Unicode.ttf', # Arial Unicode
]
elif system == 'Linux':
# Linux 字体路径
font_paths = [
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', # 文泉驿微米黑
'/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', # 文泉驿正黑
'/usr/share/fonts/truetype/arphic/uming.ttc', # AR PL UMing
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', # Noto
]
# 尝试找到并添加第一个可用的中文字体
font_added = False
for font_path in font_paths:
try:
if os.path.exists(font_path):
font_prop = mpl.font_manager.FontProperties(fname=font_path)
font_name = font_prop.get_name()
mpl.font_manager.fontManager.addfont(font_path)
plt.rcParams['font.sans-serif'] = [font_name] + plt.rcParams['font.sans-serif']
font_added = True
print(f"已添加中文字体: {font_name} ({font_path})")
break
except Exception as e:
print(f"添加字体 {font_path} 失败: {e}")
continue
# 如果以上方法都失败,使用通用备选方案
if not font_added:
if system == 'Windows':
fallback_fonts = ['SimHei', 'Microsoft YaHei', 'KaiTi', 'FangSong']
elif system == 'Darwin':
fallback_fonts = ['PingFang SC', 'Hiragino Sans GB', 'Apple LiGothic Medium']
else: # Linux and others
fallback_fonts = ['DejaVu Sans', 'WenQuanYi Micro Hei',
'Noto Sans CJK SC', 'Heiti TC', 'AR PL UMing CN']
current_fonts = plt.rcParams.get('font.sans-serif', [])
plt.rcParams['font.sans-serif'] = fallback_fonts + current_fonts
print(f"使用备选字体方案: {fallback_fonts[:2]}...")
# 设置字体族
plt.rcParams['font.family'] = 'sans-serif'
if __name__ == "__main__":
dem_path = r'D:\devForBdzlWork\ai_project_v1\b3dm\test\o_dem_df67f1cc.tif'
read_slope_aspect_by_dem(dem_path)

342
b3dm/terrain_api.py Normal file
View File

@ -0,0 +1,342 @@
from sanic import Blueprint, Request, json
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional, Dict, Any
import numpy as np
import logging
import time
import uuid
import urllib.parse
import threading
from terrain_calculator import TerrainCalculator
terrain_bp = Blueprint("terrain", url_prefix="")
MINIO_SUB_PATH = "slopeAspectPng"
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 请求模型
class NormalVector(BaseModel):
"""法向量模型"""
nx: float = Field(..., description="法向量X分量")
ny: float = Field(..., description="法向量Y分量")
nz: float = Field(..., description="法向量Z分量")
@field_validator('nx', 'ny', 'nz')
def check_finite(cls, v):
if not np.isfinite(v):
raise ValueError(f"值必须是有限数字,得到: {v}")
return v
def to_list(self):
return [self.nx, self.ny, self.nz]
class BatchRequest(BaseModel):
"""批量请求模型"""
vectors: List[List[float]] = Field(..., description="法向量列表")
@field_validator('vectors')
def validate_vectors(cls, v):
if len(v) > 1000:
raise ValueError("批量处理最多支持1000个向量")
for vec in v:
if len(vec) != 3:
raise ValueError("每个向量必须是长度为3的列表")
if not all(isinstance(x, (int, float)) for x in vec):
raise ValueError("向量元素必须是数字")
return v
class PointItem(BaseModel):
"""单个点模型"""
x: float = Field(..., description="x坐标")
y: float = Field(..., description="y坐标")
z: float = Field(..., description="z坐标")
class PointRequest(BaseModel):
"""使用Pydantic HttpUrl类型的严格点批量请求模型"""
points: List[PointItem] = Field(..., description="点列表")
url: str = Field(..., description="URL地址")
@field_validator('points')
def validate_points_count(cls, v):
if len(v) > 10:
raise ValueError("批量处理最多支持10个点")
return v
class AnalysisConfig(BaseModel):
"""分析配置"""
classify: bool = Field(default=True, description="是否进行分类")
include_percent: bool = Field(default=True, description="是否包含坡度百分比")
include_direction: bool = Field(default=True, description="是否包含方向描述")
# 中间件:请求计时
@terrain_bp.middleware("request")
async def add_start_time(request: Request):
request.ctx.start_time = time.time()
@terrain_bp.middleware("response")
async def add_response_time(request: Request, response):
if hasattr(request.ctx, "start_time"):
process_time = (time.time() - request.ctx.start_time) * 1000
response.headers["X-Process-Time"] = f"{process_time:.2f}ms"
@terrain_bp.post("/api/v1/calculate/slope")
async def calculate_slope(request: Request):
"""计算坡度"""
try:
data = request.json
if not data:
return json({"error": "请求体不能为空"}, status=400)
# 验证输入
try:
vector = NormalVector(**data)
except Exception as e:
return json({"error": f"输入验证失败: {str(e)}"}, status=400)
# 计算坡度
result = TerrainCalculator.calculate_slope(vector.to_list())
# 检查是否有错误
if "error" in result and result["error"]:
return json(result, status=400)
return json({
"success": True,
"data": result,
"request": {
"input_vector": vector.to_list(),
"timestamp": time.time()
}
})
except Exception as e:
logger.error(f"坡度计算API错误: {e}")
return json({
"error": f"服务器内部错误: {str(e)}",
"success": False
}, status=500)
@terrain_bp.post("/api/v1/calculate/aspect")
async def calculate_aspect1(request: Request):
"""计算坡向"""
try:
data = request.json
if not data:
return json({"error": "请求体不能为空"}, status=400)
# 验证输入
try:
vector = NormalVector(**data)
except Exception as e:
return json({"error": f"输入验证失败: {str(e)}"}, status=400)
# 计算坡向
result = TerrainCalculator.calculate_aspect(vector.to_list())
# 检查是否有错误
if "error" in result and result["error"]:
return json(result, status=400)
return json({
"success": True,
"data": result,
"request": {
"input_vector": vector.to_list(),
"timestamp": time.time()
}
})
except Exception as e:
logger.error(f"坡向计算API错误: {e}")
return json({
"error": f"服务器内部错误: {str(e)}",
"success": False
}, status=500)
@terrain_bp.post("/api/v1/calculate/slopeAspect")
async def calculate_slopeAspect(request: Request):
"""生成坡向坡度俯视图"""
try:
data = request.json
if not data:
return json({"error": "请求体不能为空"}, status=400)
# 验证输入
try:
vector = PointRequest(**data)
except Exception as e:
return json({"error": f"输入验证失败: {str(e)}"}, status=400)
# 生成坡向坡度俯视图
region_coords = [(point.x, point.y, point.z) for point in vector.points]
overall_3d_png_name = f"o_dem_{uuid.uuid4().hex[:8]}_slopeAspect.png"
# 创建并启动线程
thread1 = threading.Thread(target=TerrainCalculator.generate_slopeAspect_3d_overlook, args=(region_coords, vector.url, overall_3d_png_name, MINIO_SUB_PATH))
# 启动线程
thread1.start()
url_prefix = extract_and_rebuild_url(vector.url)
return json({
"success": True,
"data": f"{url_prefix}/{MINIO_SUB_PATH}/{overall_3d_png_name}",
"request": {
"input_vector": vector.model_dump(),
"timestamp": time.time()
}
})
except Exception as e:
logger.error(f"坡向计算API错误: {e}")
return json({
"error": f"服务器内部错误: {str(e)}",
"success": False
}, status=500)
@terrain_bp.post("/api/v1/calculate/both")
async def calculate_both(request: Request):
"""同时计算坡度和坡向"""
try:
data = request.json
if not data:
return json({"error": "请求体不能为空"}, status=400)
# 验证输入
try:
vector = NormalVector(**data)
except Exception as e:
return json({"error": f"输入验证失败: {str(e)}"}, status=400)
# 计算坡度和坡向
result = TerrainCalculator.calculate_slope_aspect(vector.to_list())
# 检查是否有错误
if "error" in result and result["error"]:
return json(result, status=400)
return json({
"success": True,
"data": result,
"request": {
"input_vector": vector.to_list(),
"timestamp": time.time()
}
})
except Exception as e:
logger.error(f"综合计算API错误: {e}")
return json({
"error": f"服务器内部错误: {str(e)}",
"success": False
}, status=500)
@terrain_bp.post("/api/v1/calculate/batch")
async def batch_calculate(request: Request):
"""批量计算"""
try:
data = request.json
if not data:
return json({"error": "请求体不能为空"}, status=400)
# 验证输入
try:
batch_request = BatchRequest(**data)
except Exception as e:
return json({"error": f"输入验证失败: {str(e)}"}, status=400)
# 批量计算
start_time = time.time()
result = TerrainCalculator.batch_calculate(batch_request.vectors)
process_time = (time.time() - start_time) * 1000
if "error" in result and result["error"]:
return json(result, status=400)
return json({
"success": True,
"data": result,
"performance": {
"process_time_ms": process_time,
"vectors_per_second": len(batch_request.vectors) / (process_time / 1000) if process_time > 0 else 0
},
"request": {
"vector_count": len(batch_request.vectors),
"timestamp": time.time()
}
})
except Exception as e:
logger.error(f"批量计算API错误: {e}")
return json({
"error": f"服务器内部错误: {str(e)}",
"success": False
}, status=500)
@terrain_bp.get("/api/v1/example")
async def get_examples(request: Request):
"""获取示例数据"""
examples = {
"flat": {
"nx": 0.0,
"ny": 0.0,
"nz": 1.0,
"expected_slope": 0.0,
"description": "完全水平面"
},
"north_slope_30": {
"nx": 0.0,
"ny": -0.5,
"nz": 0.8660254,
"expected_slope": 30.0,
"expected_aspect": 0.0,
"description": "朝北30度斜坡"
},
"east_slope_45": {
"nx": 0.7071068,
"ny": 0.0,
"nz": 0.7071068,
"expected_slope": 45.0,
"expected_aspect": 90.0,
"description": "朝东45度斜坡"
},
"vertical": {
"nx": 1.0,
"ny": 0.0,
"nz": 0.0,
"expected_slope": 90.0,
"description": "垂直面"
}
}
return json({
"examples": examples,
"count": len(examples)
})
def extract_and_rebuild_url(url):
"""提取URL的三部分并重建"""
# 解析URL
parsed = urllib.parse.urlparse(url)
# 1. 提取协议部分 (http/https)
scheme = parsed.scheme or "http" # 如果没有协议默认用http
# 2. 提取IP端口/主机部分
netloc = parsed.netloc
# 3. 提取第一个路径分段
path = parsed.path.strip("/") # 去掉首尾的斜杠
path_parts = path.split("/")
if path_parts and path_parts[0]:
first_segment = path_parts[0]
else:
first_segment = ""
# 重建URL
if first_segment:
rebuilt_url = f"{scheme}://{netloc}/{first_segment}"
else:
rebuilt_url = f"{scheme}://{netloc}"
return rebuilt_url

361
b3dm/terrain_calculator.py Normal file
View File

@ -0,0 +1,361 @@
import numpy as np
from typing import List, Tuple, Dict, Any
import logging
import os
import uuid
from data_3dtiles_manager import MinIO3DTilesManager
import data_3dtiles_to_dem
import terrain3d_analyzer_color
logger = logging.getLogger(__name__)
class TerrainCalculator:
"""地形坡度和坡向计算器"""
def generate_slopeAspect_3d_overlook(region_coords, url, overall_3d_png_name, minio_sub_path) :
# 下载3dtiles地图数据
ENDPOINT_URL = "222.212.85.86:9000"
ACCESS_KEY = "WuRenJi"
SECRET_KEY = "WRJ@2024"
manager = MinIO3DTilesManager(
endpoint_url=ENDPOINT_URL,
access_key=ACCESS_KEY,
secret_key=SECRET_KEY,
secure=False
)
script_dir = os.path.dirname(os.path.abspath(__file__))
success, entry_local_path = manager.download_full_tileset(
tileset_url=url,
save_dir=f"{script_dir}/data_3dtiles",
region_filter=None
)
if not success :
logger.info(f"下载地图数据失败: {url},{region_coords}")
return "下载地图数据失败", None
tileset_path = entry_local_path
dem_path = os.path.join(script_dir, f"o_dem_{uuid.uuid4().hex[:8]}.tif")
data_3dtiles_to_dem.generate_dem(region_coords, tileset_path, dem_path)
if not os.path.exists(dem_path) :
logger.info(f"生成坡度坡向俯视图失败: {url},{region_coords}")
return "生成坡度坡向俯视图失败", None
overall_3d_png_path = os.path.join(script_dir, overall_3d_png_name)
terrain3d_analyzer_color.read_slope_aspect_by_dem(dem_path, overall_3d_png_path)
logger.info(f"生成成功: {url},{region_coords},{overall_3d_png_path}")
entry_bucket, _ = manager.parse_minio_url(url);
success, minio_path = manager.upload_file(entry_bucket, f"{minio_sub_path}/{overall_3d_png_name}", overall_3d_png_path)
if success :
return "生成成功", minio_path
else :
return "生成失败", None
@staticmethod
def validate_vector(vector: List[float]) -> bool:
"""验证输入向量是否有效"""
if len(vector) != 3:
return False
if not all(isinstance(v, (int, float)) for v in vector):
return False
norm = np.linalg.norm(vector)
return norm > 1e-10 # 避免零向量
@staticmethod
def normalize_vector(vector: List[float]) -> np.ndarray:
"""向量归一化"""
arr = np.array(vector, dtype=np.float64)
norm = np.linalg.norm(arr)
return arr / norm if norm > 0 else arr
@staticmethod
def calculate_slope(normal_vector: List[float]) -> Dict[str, Any]:
"""
计算坡度
Args:
normal_vector: 法向量 [nx, ny, nz]
Returns:
dict: 包含坡度()和相关信息
"""
try:
# 验证输入
if not TerrainCalculator.validate_vector(normal_vector):
return {
"error": "无效的法向量,必须是长度为3的数值列表且不能为零向量",
"slope_deg": None
}
# 归一化
n = TerrainCalculator.normalize_vector(normal_vector)
# 计算坡度使用arccos法
nz_abs = abs(n[2])
# 处理数值误差
if nz_abs > 1.0:
nz_abs = 1.0
elif nz_abs < 0.0:
nz_abs = 0.0
# 计算坡度(弧度)
if abs(nz_abs - 1.0) < 1e-10: # 完全水平
slope_rad = 0.0
elif abs(nz_abs) < 1e-10: # 完全垂直
slope_rad = np.pi / 2
else:
slope_rad = np.arccos(nz_abs)
# 转换为度
slope_deg = np.degrees(slope_rad)
# 计算坡度百分比
slope_percent = np.tan(slope_rad) * 100 if slope_rad < np.pi/2 else float('inf')
return {
"slope_deg": float(slope_deg),
"slope_rad": float(slope_rad),
"slope_percent": float(slope_percent),
"normalized_vector": n.tolist(),
"classification": TerrainCalculator.classify_slope(slope_deg)
}
except Exception as e:
logger.error(f"坡度计算错误: {e}")
return {
"error": f"计算失败: {str(e)}",
"slope_deg": None
}
@staticmethod
def calculate_aspect(normal_vector: List[float]) -> Dict[str, Any]:
"""
计算坡向
Args:
normal_vector: 法向量 [nx, ny, nz]
Returns:
dict: 包含坡向()和相关信息
"""
try:
# 验证输入
if not TerrainCalculator.validate_vector(normal_vector):
return {
"error": "无效的法向量,必须是长度为3的数值列表且不能为零向量",
"aspect_deg": None
}
# 归一化
n = TerrainCalculator.normalize_vector(normal_vector)
# 检查是否为水平面
nx, ny, nz = n
horizontal_magnitude = np.sqrt(nx*nx + ny*ny)
if horizontal_magnitude < 1e-10: # 水平面,坡向无定义
return {
"aspect_deg": None,
"aspect_rad": None,
"is_flat": True,
"message": "水平面,坡向无定义",
"normalized_vector": n.tolist()
}
# 计算原始坡向(四象限反正切)
# 注意arctan2(nx, ny) 不是 arctan2(ny, nx)
raw_angle_rad = np.arctan2(nx, ny)
# 转换为坡向(下坡方向 = 法向量方向 + 180°
aspect_rad = raw_angle_rad + np.pi
# 转换为度
aspect_deg = np.degrees(aspect_rad)
# 归一化到 [0, 360) 范围
aspect_deg = aspect_deg % 360.0
# 转换为八方向
direction = TerrainCalculator.aspect_to_direction(aspect_deg)
return {
"aspect_deg": float(aspect_deg),
"aspect_rad": float(aspect_rad % (2*np.pi)),
"direction": direction,
"is_flat": False,
"normalized_vector": n.tolist(),
"raw_angle_deg": float(np.degrees(raw_angle_rad))
}
except Exception as e:
logger.error(f"坡向计算错误: {e}")
return {
"error": f"计算失败: {str(e)}",
"aspect_deg": None
}
@staticmethod
def calculate_slope_aspect(normal_vector: List[float]) -> Dict[str, Any]:
"""
同时计算坡度和坡向
Args:
normal_vector: 法向量 [nx, ny, nz]
Returns:
dict: 包含坡度和坡向的综合结果
"""
try:
slope_result = TerrainCalculator.calculate_slope(normal_vector)
aspect_result = TerrainCalculator.calculate_aspect(normal_vector)
result = {
"slope": slope_result,
"aspect": aspect_result,
"input_vector": normal_vector,
"calculation_time": None # 可在调用处添加时间戳
}
# 如果有错误,合并错误信息
errors = []
if "error" in slope_result and slope_result["error"]:
errors.append(f"坡度: {slope_result['error']}")
if "error" in aspect_result and aspect_result["error"]:
errors.append(f"坡向: {aspect_result['error']}")
if errors:
result["errors"] = errors
return result
except Exception as e:
logger.error(f"综合计算错误: {e}")
return {
"error": f"综合计算失败: {str(e)}"
}
@staticmethod
def classify_slope(slope_deg: float) -> Dict[str, Any]:
"""坡度分类"""
if slope_deg < 2:
return {"category": "平坦", "level": 0, "description": "基本平坦"}
elif slope_deg < 5:
return {"category": "缓坡", "level": 1, "description": "适合农业"}
elif slope_deg < 15:
return {"category": "斜坡", "level": 2, "description": "适合建设"}
elif slope_deg < 30:
return {"category": "陡坡", "level": 3, "description": "需要工程措施"}
elif slope_deg < 45:
return {"category": "急陡坡", "level": 4, "description": "高风险区域"}
else:
return {"category": "峭壁", "level": 5, "description": "危险区域"}
@staticmethod
def aspect_to_direction(aspect_deg: float) -> Dict[str, Any]:
"""将坡向转换为八方向"""
directions = ["", "东北", "", "东南", "", "西南", "西", "西北"]
# 计算方向索引 (45°一个区间)
index = int((aspect_deg + 22.5) % 360 / 45)
return {
"chinese": directions[index],
"english": ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][index],
"degree_range": {
"min": (index * 45 - 22.5) % 360,
"max": (index * 45 + 22.5) % 360
}
}
@staticmethod
def batch_calculate(vectors: List[List[float]]) -> Dict[str, Any]:
"""批量计算多个法向量"""
try:
results = []
errors = []
for i, vec in enumerate(vectors):
try:
result = TerrainCalculator.calculate_slope_aspect(vec)
result["index"] = i
results.append(result)
except Exception as e:
errors.append({
"index": i,
"vector": vec,
"error": str(e)
})
return {
"total": len(vectors),
"successful": len(results),
"failed": len(errors),
"results": results,
"errors": errors,
"statistics": TerrainCalculator.calculate_statistics(results)
}
except Exception as e:
logger.error(f"批量计算错误: {e}")
return {"error": f"批量计算失败: {str(e)}"}
@staticmethod
def calculate_statistics(results: List[Dict]) -> Dict[str, Any]:
"""计算统计信息"""
if not results:
return {}
slope_values = []
aspect_values = []
for r in results:
if "slope" in r and "slope_deg" in r["slope"] and r["slope"]["slope_deg"] is not None:
slope_values.append(r["slope"]["slope_deg"])
if "aspect" in r and "aspect_deg" in r["aspect"] and r["aspect"]["aspect_deg"] is not None:
aspect_values.append(r["aspect"]["aspect_deg"])
if slope_values:
slope_arr = np.array(slope_values)
aspect_arr = np.array(aspect_values) if aspect_values else np.array([])
stats = {
"slope": {
"count": len(slope_values),
"mean": float(np.mean(slope_arr)),
"std": float(np.std(slope_arr)),
"min": float(np.min(slope_arr)),
"max": float(np.max(slope_arr)),
"median": float(np.median(slope_arr))
}
}
if aspect_values:
# 坡向统计需要循环统计
stats["aspect"] = {
"count": len(aspect_values),
"mean_vector": TerrainCalculator.circular_mean(aspect_arr),
"concentration": TerrainCalculator.circular_concentration(aspect_arr)
}
return stats
return {}
@staticmethod
def circular_mean(angles_deg: np.ndarray) -> float:
"""计算循环数据的平均值(角度)"""
angles_rad = np.radians(angles_deg)
x = np.mean(np.cos(angles_rad))
y = np.mean(np.sin(angles_rad))
mean_rad = np.arctan2(y, x)
return np.degrees(mean_rad) % 360
@staticmethod
def circular_concentration(angles_deg: np.ndarray) -> float:
"""计算角度数据的集中度 (0-1)"""
angles_rad = np.radians(angles_deg)
x = np.mean(np.cos(angles_rad))
y = np.mean(np.sin(angles_rad))
return np.sqrt(x*x + y*y)

167
b3dm/tileset_data_source.py Normal file
View File

@ -0,0 +1,167 @@
# tileset_data_source_py
import numpy as np
from typing import List, Dict, Optional, Tuple
import asyncio
from concurrent.futures import ThreadPoolExecutor
import logging
import os
logger = logging.getLogger(__name__)
class TilesetDataSource:
"""使用py3dtiles库的数据源"""
def __init__(self, tileset_path: str, cache_size: int = 1000):
self.tileset_path = os.path.abspath(tileset_path)
self.tileset_dir = os.path.dirname(self.tileset_path)
self.cache_size = cache_size
self._tileset = None
self._point_cache = {}
self._executor = ThreadPoolExecutor(max_workers=4)
self._crs = "EPSG:4979"
async def initialize(self):
"""初始化"""
try:
# 尝试导入py3dtiles
try:
import py3dtiles
from py3dtiles.tileset import TileSet
except ImportError:
logger.warning("py3dtiles未安装将使用简化数据源")
raise ImportError("请安装py3dtiles: pip install py3dtiles")
loop = asyncio.get_event_loop()
self._tileset = await loop.run_in_executor(
self._executor,
TileSet.from_file,
self.tileset_path
)
logger.info(f"py3dtiles数据源初始化完成: {self.tileset_path}")
except Exception as e:
logger.error(f"py3dtiles初始化失败: {str(e)}")
# 回退到简化数据源
self._tileset = None
async def get_points_in_polygon(self,
polygon_coords: List[List[float]],
z_range: Optional[Tuple[float, float]] = None) -> np.ndarray:
"""获取点数据"""
if self._tileset is None:
# 使用简化数据源
return await self._get_simulated_points(polygon_coords, z_range)
try:
# 使用py3dtiles API获取数据
points = []
polygon_np = np.array(polygon_coords)
# 遍历tileset中的所有tile
for tile in self._tileset.root_tile.traverse():
tile_points = self._extract_tile_points(tile)
if tile_points.size > 0:
# 筛选多边形内的点
points_in_polygon = self._filter_points_by_polygon(tile_points, polygon_np)
if points_in_polygon.size > 0:
points.append(points_in_polygon)
if points:
all_points = np.vstack(points)
if z_range:
mask = (all_points[:, 2] >= z_range[0]) & (all_points[:, 2] <= z_range[1])
all_points = all_points[mask]
return all_points
return np.array([])
except Exception as e:
logger.error(f"py3dtiles获取数据失败: {str(e)}")
return await self._get_simulated_points(polygon_coords, z_range)
def _extract_tile_points(self, tile) -> np.ndarray:
"""从tile提取点数据"""
try:
if hasattr(tile, 'content') and tile.content:
# 尝试获取点数据
if hasattr(tile.content, 'points'):
return tile.content.points.positions
elif hasattr(tile.content, 'body'):
# 处理其他格式
return np.array([])
return np.array([])
except:
return np.array([])
def _filter_points_by_polygon(self, points: np.ndarray, polygon: np.ndarray) -> np.ndarray:
"""筛选多边形内的点"""
from matplotlib.path import Path
if points.size == 0:
return points
path = Path(polygon[:, :2])
mask = path.contains_points(points[:, :2])
return points[mask]
async def _get_simulated_points(self,
polygon_coords: List[List[float]],
z_range: Optional[Tuple[float, float]] = None) -> np.ndarray:
"""获取模拟点数据"""
# 同上一个类的生成模拟数据方法
polygon_np = np.array(polygon_coords)
if polygon_np.shape[0] < 3:
return np.array([])
x_min, y_min = polygon_np.min(axis=0)
x_max, y_max = polygon_np.max(axis=0)
grid_size = 100
x = np.linspace(x_min, x_max, grid_size)
y = np.linspace(y_min, y_max, grid_size)
xx, yy = np.meshgrid(x, y)
points = np.column_stack([xx.ravel(), yy.ravel()])
np.random.seed(42)
base_elevation = 50.0
terrain_variation = 10.0
z = base_elevation + terrain_variation * np.sin(0.1 * xx).ravel() * np.cos(0.1 * yy).ravel()
points = np.column_stack([points[:, 0], points[:, 1], z])
from matplotlib.path import Path
path = Path(polygon_np[:, :2])
mask = path.contains_points(points[:, :2])
points = points[mask]
if z_range:
mask = (points[:, 2] >= z_range[0]) & (points[:, 2] <= z_range[1])
points = points[mask]
logger.info(f"生成 {points.shape[0]} 个模拟点")
return points
async def get_data_bounds(self) -> Dict[str, List[float]]:
"""获取数据边界"""
if self._tileset is None:
await self.initialize()
bounds = {
"min": [float('inf'), float('inf'), float('inf')],
"max": [-float('inf'), -float('inf'), -float('inf')]
}
if self._tileset and hasattr(self._tileset.root_tile, 'bounding_volume'):
bv = self._tileset.root_tile.bounding_volume
if hasattr(bv, 'get_corners'):
corners = bv.get_corners()
if corners is not None:
bounds["min"] = corners.min(axis=0).tolist()
bounds["max"] = corners.max(axis=0).tolist()
return bounds
def get_crs(self) -> str:
return self._crs

395
b3dm/tileset_to_ply.py Normal file
View File

@ -0,0 +1,395 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
3D Tiles Tileset to PLY Converter
将整个3D Tiles tileset转换为单个PLY文件
"""
import json
import struct
import os
import sys
from pathlib import Path
import numpy as np
try:
import DracoPy
except ImportError:
print("警告: DracoPy库未安装无法处理Draco压缩的数据")
print("请运行: pip install DracoPy")
DracoPy = None
class TilesetToPLYConverter:
def __init__(self):
self.all_vertices = []
self.vertex_count = 0
def multiply_matrix_vector(self, matrix, vector):
"""4x4矩阵与4D向量相乘"""
# matrix是16个元素的列表按列主序排列
# 转换为4x4矩阵行主序
m = [
[matrix[0], matrix[4], matrix[8], matrix[12]],
[matrix[1], matrix[5], matrix[9], matrix[13]],
[matrix[2], matrix[6], matrix[10], matrix[14]],
[matrix[3], matrix[7], matrix[11], matrix[15]]
]
# 向量扩展为齐次坐标 [x, y, z, 1]
v = [vector[0], vector[1], vector[2], 1.0]
# 矩阵乘法
result = [
m[0][0]*v[0] + m[0][1]*v[1] + m[0][2]*v[2] + m[0][3]*v[3],
m[1][0]*v[0] + m[1][1]*v[1] + m[1][2]*v[2] + m[1][3]*v[3],
m[2][0]*v[0] + m[2][1]*v[1] + m[2][2]*v[2] + m[2][3]*v[3]
]
return result
def multiply_matrices(self, m1, m2):
"""两个4x4矩阵相乘"""
# 将16元素列表转换为4x4矩阵
def list_to_matrix(lst):
return [
[lst[0], lst[4], lst[8], lst[12]],
[lst[1], lst[5], lst[9], lst[13]],
[lst[2], lst[6], lst[10], lst[14]],
[lst[3], lst[7], lst[11], lst[15]]
]
def matrix_to_list(mat):
return [
mat[0][0], mat[1][0], mat[2][0], mat[3][0],
mat[0][1], mat[1][1], mat[2][1], mat[3][1],
mat[0][2], mat[1][2], mat[2][2], mat[3][2],
mat[0][3], mat[1][3], mat[2][3], mat[3][3]
]
a = list_to_matrix(m1)
b = list_to_matrix(m2)
result = [[0 for _ in range(4)] for _ in range(4)]
for i in range(4):
for j in range(4):
for k in range(4):
result[i][j] += a[i][k] * b[k][j]
return matrix_to_list(result)
def apply_transform_to_vertices(self, vertices, transform_matrix):
"""对顶点应用变换矩阵"""
if not transform_matrix:
return vertices
transformed_vertices = []
for vertex in vertices:
transformed = self.multiply_matrix_vector(transform_matrix, vertex)
transformed_vertices.append(transformed)
return transformed_vertices
def parse_tileset_json(self, tileset_path, parent_transform=None):
"""解析tileset.json文件收集B3DM文件和变换矩阵"""
try:
with open(tileset_path, 'r', encoding='utf-8') as f:
tileset_data = json.load(f)
b3dm_files = []
def process_node(node, base_path, accumulated_transform):
# 获取当前节点的变换矩阵
current_transform = node.get('transform')
# 计算累积变换矩阵
if current_transform and accumulated_transform:
# 矩阵相乘accumulated_transform * current_transform
final_transform = self.multiply_matrices(accumulated_transform, current_transform)
elif current_transform:
final_transform = current_transform
else:
final_transform = accumulated_transform
if 'content' in node and 'uri' in node['content']:
uri = node['content']['uri']
if uri.endswith('.b3dm'):
full_path = os.path.join(base_path, uri)
if os.path.exists(full_path):
b3dm_files.append((full_path, final_transform))
elif uri.endswith('.json'):
# 递归处理子tileset
sub_tileset_path = os.path.join(base_path, uri)
if os.path.exists(sub_tileset_path):
sub_files = self.parse_tileset_json(sub_tileset_path, final_transform)
b3dm_files.extend(sub_files)
if 'children' in node:
for child in node['children']:
process_node(child, base_path, final_transform)
base_path = os.path.dirname(tileset_path)
if 'root' in tileset_data:
process_node(tileset_data['root'], base_path, parent_transform)
return b3dm_files
except Exception as e:
print(f"解析tileset.json时出错: {e}")
return []
def parse_b3dm_file(self, file_path):
"""解析B3DM文件"""
try:
with open(file_path, 'rb') as f:
# 读取B3DM头部
magic = f.read(4)
if magic != b'b3dm':
print(f"警告: {file_path} 不是有效的B3DM文件")
return None
version = struct.unpack('<I', f.read(4))[0]
byte_length = struct.unpack('<I', f.read(4))[0]
feature_table_json_byte_length = struct.unpack('<I', f.read(4))[0]
feature_table_binary_byte_length = struct.unpack('<I', f.read(4))[0]
batch_table_json_byte_length = struct.unpack('<I', f.read(4))[0]
batch_table_binary_byte_length = struct.unpack('<I', f.read(4))[0]
# 跳过feature table和batch table
f.seek(28 + feature_table_json_byte_length + feature_table_binary_byte_length +
batch_table_json_byte_length + batch_table_binary_byte_length)
# 读取glTF数据
gltf_data = f.read()
return self.parse_gltf_data(gltf_data)
except Exception as e:
print(f"解析B3DM文件 {file_path} 失败: {e}")
return None
def parse_gltf_data(self, gltf_data):
"""解析glTF数据"""
try:
# 检查是否为GLB格式
if gltf_data[:4] == b'glTF':
return self.parse_glb_data(gltf_data)
else:
# 尝试作为JSON解析
gltf_json = json.loads(gltf_data.decode('utf-8'))
return self.extract_vertices_from_gltf(gltf_json, None)
except Exception as e:
print(f"解析glTF数据失败: {e}")
return None
def parse_glb_data(self, glb_data):
"""解析GLB格式的glTF数据"""
try:
# GLB头部: magic(4) + version(4) + length(4)
magic = glb_data[:4]
if magic != b'glTF':
return None
version = struct.unpack('<I', glb_data[4:8])[0]
total_length = struct.unpack('<I', glb_data[8:12])[0]
offset = 12
json_data = None
binary_data = None
# 读取chunks
while offset < len(glb_data):
if offset + 8 > len(glb_data):
break
chunk_length = struct.unpack('<I', glb_data[offset:offset+4])[0]
chunk_type = glb_data[offset+4:offset+8]
chunk_data = glb_data[offset+8:offset+8+chunk_length]
if chunk_type == b'JSON':
json_data = json.loads(chunk_data.decode('utf-8'))
elif chunk_type == b'BIN\x00':
binary_data = chunk_data
offset += 8 + chunk_length
# 对齐到4字节边界
offset = (offset + 3) & ~3
if json_data:
return self.extract_vertices_from_gltf(json_data, binary_data)
except Exception as e:
print(f"解析GLB数据失败: {e}")
return None
def extract_vertices_from_gltf(self, gltf_json, binary_data):
"""从glTF JSON中提取顶点数据"""
vertices = []
try:
# 检查是否使用了Draco压缩
if 'extensionsUsed' in gltf_json and 'KHR_draco_mesh_compression' in gltf_json['extensionsUsed']:
if DracoPy is None:
print("警告: 检测到Draco压缩但DracoPy未安装")
return vertices
return self.extract_draco_vertices(gltf_json, binary_data)
# 处理标准glTF格式
if 'meshes' not in gltf_json:
return vertices
for mesh in gltf_json['meshes']:
for primitive in mesh['primitives']:
if 'attributes' in primitive and 'POSITION' in primitive['attributes']:
position_accessor_index = primitive['attributes']['POSITION']
if 'accessors' in gltf_json and position_accessor_index < len(gltf_json['accessors']):
accessor = gltf_json['accessors'][position_accessor_index]
buffer_view_index = accessor['bufferView']
if 'bufferViews' in gltf_json and buffer_view_index < len(gltf_json['bufferViews']):
buffer_view = gltf_json['bufferViews'][buffer_view_index]
buffer_index = buffer_view['buffer']
byte_offset = buffer_view.get('byteOffset', 0) + accessor.get('byteOffset', 0)
if binary_data and buffer_index == 0:
# 从二进制数据中读取顶点
component_type = accessor['componentType']
count = accessor['count']
if component_type == 5126: # FLOAT
vertex_data = struct.unpack(f'<{count*3}f',
binary_data[byte_offset:byte_offset+count*12])
for i in range(0, len(vertex_data), 3):
vertices.append([vertex_data[i], vertex_data[i+1], vertex_data[i+2]])
except Exception as e:
print(f"提取顶点数据失败: {e}")
return vertices
def extract_draco_vertices(self, gltf_json, binary_data):
"""提取Draco压缩的顶点数据"""
vertices = []
try:
if 'meshes' not in gltf_json:
return vertices
for mesh in gltf_json['meshes']:
for primitive in mesh['primitives']:
if 'extensions' in primitive and 'KHR_draco_mesh_compression' in primitive['extensions']:
draco_ext = primitive['extensions']['KHR_draco_mesh_compression']
buffer_view_index = draco_ext['bufferView']
if 'bufferViews' in gltf_json and buffer_view_index < len(gltf_json['bufferViews']):
buffer_view = gltf_json['bufferViews'][buffer_view_index]
byte_offset = buffer_view.get('byteOffset', 0)
byte_length = buffer_view['byteLength']
if binary_data:
draco_data = binary_data[byte_offset:byte_offset+byte_length]
# 使用DracoPy解码
mesh_data = DracoPy.decode(draco_data)
if hasattr(mesh_data, 'points'):
points = mesh_data.points
for point in points:
vertices.append([float(point[0]), float(point[1]), float(point[2])])
except Exception as e:
print(f"解码Draco数据失败: {e}")
return vertices
def save_ply_file(self, output_path):
"""保存PLY文件"""
try:
with open(output_path, 'w') as f:
# 写入PLY头部
f.write("ply\n")
f.write("format ascii 1.0\n")
f.write(f"element vertex {len(self.all_vertices)}\n")
f.write("property float x\n")
f.write("property float y\n")
f.write("property float z\n")
f.write("end_header\n")
# 写入顶点数据
for vertex in self.all_vertices:
f.write(f"{vertex[0]} {vertex[1]} {vertex[2]}\n")
print(f"PLY文件已保存: {output_path}")
print(f"总顶点数: {len(self.all_vertices)}")
except Exception as e:
print(f"保存PLY文件失败: {e}")
def convert_tileset_to_ply(self, tileset_path, output_path):
"""将整个tileset转换为PLY文件"""
print(f"开始处理tileset: {tileset_path}")
# 解析主tileset.json
tileset_data = self.parse_tileset_json(tileset_path)
if not tileset_data:
print("无法解析tileset.json文件")
return False
# 获取基础路径
base_path = os.path.dirname(tileset_path)
# 提取所有b3dm文件和变换矩阵
b3dm_files = self.parse_tileset_json(tileset_path)
print(f"找到 {len(b3dm_files)} 个B3DM文件")
if not b3dm_files:
print("未找到任何B3DM文件")
return False
# 处理每个b3dm文件
processed_count = 0
for i, (b3dm_file, transform_matrix) in enumerate(b3dm_files):
print(f"处理文件 {i+1}/{len(b3dm_files)}: {os.path.basename(b3dm_file)}")
vertices = self.parse_b3dm_file(b3dm_file)
if vertices:
# 应用变换矩阵
if transform_matrix:
vertices = self.apply_transform_to_vertices(vertices, transform_matrix)
print(f" 应用了变换矩阵")
self.all_vertices.extend(vertices)
processed_count += 1
print(f" 提取到 {len(vertices)} 个顶点")
print(f"\n成功处理 {processed_count}/{len(b3dm_files)} 个文件")
print(f"总计提取 {len(self.all_vertices)} 个顶点")
if self.all_vertices:
self.save_ply_file(output_path)
return True
else:
print("未提取到任何顶点数据")
return False
def main():
if len(sys.argv) < 2:
print("用法: python tileset_to_ply.py <tileset.json路径> [输出PLY文件路径]")
print("示例: python tileset_to_ply.py tileset.json output.ply")
return
tileset_path = sys.argv[1]
output_path = sys.argv[2] if len(sys.argv) > 2 else "merged_tileset.ply"
if not os.path.exists(tileset_path):
print(f"错误: 文件不存在 {tileset_path}")
return
converter = TilesetToPLYConverter()
success = converter.convert_tileset_to_ply(tileset_path, output_path)
if success:
print("\n转换完成!")
else:
print("\n转换失败!")
if __name__ == "__main__":
main()

684
b3dm/volume_calculator.py Normal file
View File

@ -0,0 +1,684 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
三维模型体积计算器
基于指定地理范围内的三维模型数据使用三角构网方法计算体积
"""
import json
import struct
import os
import sys
from pathlib import Path
import numpy as np
from scipy.spatial import Delaunay
import math
from tqdm import tqdm
try:
import DracoPy
except ImportError:
print("警告: DracoPy库未安装无法处理Draco压缩的数据")
print("请运行: pip install DracoPy")
DracoPy = None
class VolumeCalculator:
def __init__(self, location_file):
self.location_bounds = self.load_location_bounds(location_file)
self.all_vertices = []
self.filtered_vertices = []
def load_location_bounds(self, location_file):
"""加载地理范围边界"""
try:
with open(location_file, 'r', encoding='utf-8') as f:
coords = json.load(f)
# 提取经纬度范围
lons = [coord[0] for coord in coords]
lats = [coord[1] for coord in coords]
elevs = [coord[2] for coord in coords]
bounds = {
'min_lon': min(lons),
'max_lon': max(lons),
'min_lat': min(lats),
'max_lat': max(lats),
'min_elev': min(elevs),
'max_elev': max(elevs),
'coords': coords
}
print(f"地理范围边界:")
print(f" 经度: {bounds['min_lon']:.8f} ~ {bounds['max_lon']:.8f}")
print(f" 纬度: {bounds['min_lat']:.8f} ~ {bounds['max_lat']:.8f}")
print(f" 高程: {bounds['min_elev']:.2f} ~ {bounds['max_elev']:.2f}")
return bounds
except Exception as e:
print(f"加载地理范围文件失败: {e}")
return None
def wgs84_to_cartesian(self, lon, lat, elev):
"""WGS84坐标转换为笛卡尔坐标高精度算法"""
# WGS84椭球参数EPSG:4326
a = 6378137.0 # 长半轴 (米)
f = 1/298.257223563 # 扁率
e2 = 2*f - f*f # 第一偏心率平方
# 角度转弧度
lon_rad = math.radians(lon)
lat_rad = math.radians(lat)
# 计算卯酉圈曲率半径
sin_lat = math.sin(lat_rad)
cos_lat = math.cos(lat_rad)
sin_lon = math.sin(lon_rad)
cos_lon = math.cos(lon_rad)
N = a / math.sqrt(1 - e2 * sin_lat * sin_lat)
# 高精度笛卡尔坐标计算
x = (N + elev) * cos_lat * cos_lon
y = (N + elev) * cos_lat * sin_lon
z = (N * (1 - e2) + elev) * sin_lat
return [x, y, z]
def cartesian_to_wgs84(self, x, y, z):
"""笛卡尔坐标转换为WGS84坐标高精度迭代法"""
# WGS84椭球参数
a = 6378137.0 # 长半轴
f = 1/298.257223563 # 扁率
e2 = 2*f - f*f # 第一偏心率平方
ep2 = e2 / (1 - e2) # 第二偏心率平方
# 计算经度(精确值)
lon = math.atan2(y, x)
# 计算纬度和高程使用Bowring迭代法
p = math.sqrt(x*x + y*y)
if p == 0:
# 极点情况
lat = math.pi/2 if z > 0 else -math.pi/2
elev = abs(z) - a * math.sqrt(1 - e2)
return [math.degrees(lon), math.degrees(lat), elev]
# 初始估计
theta = math.atan2(z, p * (1 - f))
lat_prev = math.atan2(z + ep2 * a * (1 - f) * math.sin(theta)**3,
p - e2 * a * math.cos(theta)**3)
# 迭代求解纬度
max_iterations = 10
tolerance = 1e-12
for i in range(max_iterations):
N = a / math.sqrt(1 - e2 * math.sin(lat_prev)**2)
elev = p / math.cos(lat_prev) - N
# 更新纬度估计
lat_new = math.atan2(z + e2 * N * math.sin(lat_prev), p)
# 检查收敛性
if abs(lat_new - lat_prev) < tolerance:
break
lat_prev = lat_new
# 最终计算高程
N = a / math.sqrt(1 - e2 * math.sin(lat_prev)**2)
elev = p / math.cos(lat_prev) - N
return [math.degrees(lon), math.degrees(lat_prev), elev]
def is_point_in_bounds(self, vertex):
"""检查点是否在指定的地理范围内"""
if not self.location_bounds:
return True
# 将笛卡尔坐标转换为WGS84
try:
lon, lat, elev = self.cartesian_to_wgs84(vertex[0], vertex[1], vertex[2])
# 检查是否在边界范围内
return (self.location_bounds['min_lon'] <= lon <= self.location_bounds['max_lon'] and
self.location_bounds['min_lat'] <= lat <= self.location_bounds['max_lat'])
except:
return False
def multiply_matrix_vector(self, matrix, vector):
"""4x4矩阵与4D向量相乘"""
m = [
[matrix[0], matrix[4], matrix[8], matrix[12]],
[matrix[1], matrix[5], matrix[9], matrix[13]],
[matrix[2], matrix[6], matrix[10], matrix[14]],
[matrix[3], matrix[7], matrix[11], matrix[15]]
]
v = [vector[0], vector[1], vector[2], 1.0]
result = [
m[0][0]*v[0] + m[0][1]*v[1] + m[0][2]*v[2] + m[0][3]*v[3],
m[1][0]*v[0] + m[1][1]*v[1] + m[1][2]*v[2] + m[1][3]*v[3],
m[2][0]*v[0] + m[2][1]*v[1] + m[2][2]*v[2] + m[2][3]*v[3]
]
return result
def multiply_matrices(self, m1, m2):
"""两个4x4矩阵相乘"""
def list_to_matrix(lst):
return [
[lst[0], lst[4], lst[8], lst[12]],
[lst[1], lst[5], lst[9], lst[13]],
[lst[2], lst[6], lst[10], lst[14]],
[lst[3], lst[7], lst[11], lst[15]]
]
def matrix_to_list(mat):
return [
mat[0][0], mat[1][0], mat[2][0], mat[3][0],
mat[0][1], mat[1][1], mat[2][1], mat[3][1],
mat[0][2], mat[1][2], mat[2][2], mat[3][2],
mat[0][3], mat[1][3], mat[2][3], mat[3][3]
]
a = list_to_matrix(m1)
b = list_to_matrix(m2)
result = [[0 for _ in range(4)] for _ in range(4)]
for i in range(4):
for j in range(4):
for k in range(4):
result[i][j] += a[i][k] * b[k][j]
return matrix_to_list(result)
def apply_transform_to_vertices(self, vertices, transform_matrix):
"""对顶点应用变换矩阵"""
if not transform_matrix:
return vertices
transformed_vertices = []
for vertex in vertices:
transformed = self.multiply_matrix_vector(transform_matrix, vertex)
transformed_vertices.append(transformed)
return transformed_vertices
def parse_tileset_json(self, tileset_path, parent_transform=None):
"""解析tileset.json文件收集B3DM文件和变换矩阵"""
try:
with open(tileset_path, 'r', encoding='utf-8') as f:
tileset_data = json.load(f)
b3dm_files = []
def process_node(node, base_path, accumulated_transform):
current_transform = node.get('transform')
if current_transform and accumulated_transform:
final_transform = self.multiply_matrices(accumulated_transform, current_transform)
elif current_transform:
final_transform = current_transform
else:
final_transform = accumulated_transform
if 'content' in node and 'uri' in node['content']:
uri = node['content']['uri']
if uri.endswith('.b3dm'):
full_path = os.path.join(base_path, uri)
if os.path.exists(full_path):
b3dm_files.append((full_path, final_transform))
elif uri.endswith('.json'):
sub_tileset_path = os.path.join(base_path, uri)
if os.path.exists(sub_tileset_path):
sub_files = self.parse_tileset_json(sub_tileset_path, final_transform)
b3dm_files.extend(sub_files)
if 'children' in node:
for child in node['children']:
process_node(child, base_path, final_transform)
base_path = os.path.dirname(tileset_path)
if 'root' in tileset_data:
process_node(tileset_data['root'], base_path, parent_transform)
return b3dm_files
except Exception as e:
print(f"解析tileset.json时出错: {e}")
return []
def parse_b3dm_file(self, file_path):
"""解析B3DM文件"""
try:
with open(file_path, 'rb') as f:
magic = f.read(4)
if magic != b'b3dm':
return None
version = struct.unpack('<I', f.read(4))[0]
byte_length = struct.unpack('<I', f.read(4))[0]
feature_table_json_byte_length = struct.unpack('<I', f.read(4))[0]
feature_table_binary_byte_length = struct.unpack('<I', f.read(4))[0]
batch_table_json_byte_length = struct.unpack('<I', f.read(4))[0]
batch_table_binary_byte_length = struct.unpack('<I', f.read(4))[0]
f.seek(28 + feature_table_json_byte_length + feature_table_binary_byte_length +
batch_table_json_byte_length + batch_table_binary_byte_length)
gltf_data = f.read()
return self.parse_gltf_data(gltf_data)
except Exception as e:
print(f"解析B3DM文件 {file_path} 失败: {e}")
return None
def parse_gltf_data(self, gltf_data):
"""解析glTF数据"""
try:
if gltf_data[:4] == b'glTF':
return self.parse_glb_data(gltf_data)
else:
gltf_json = json.loads(gltf_data.decode('utf-8'))
return self.extract_vertices_from_gltf(gltf_json, None)
except Exception as e:
print(f"解析glTF数据失败: {e}")
return None
def parse_glb_data(self, glb_data):
"""解析GLB格式的glTF数据"""
try:
magic = glb_data[:4]
if magic != b'glTF':
return None
version = struct.unpack('<I', glb_data[4:8])[0]
total_length = struct.unpack('<I', glb_data[8:12])[0]
offset = 12
json_data = None
binary_data = None
while offset < len(glb_data):
if offset + 8 > len(glb_data):
break
chunk_length = struct.unpack('<I', glb_data[offset:offset+4])[0]
chunk_type = glb_data[offset+4:offset+8]
chunk_data = glb_data[offset+8:offset+8+chunk_length]
if chunk_type == b'JSON':
json_data = json.loads(chunk_data.decode('utf-8'))
elif chunk_type == b'BIN\x00':
binary_data = chunk_data
offset += 8 + chunk_length
offset = (offset + 3) & ~3
if json_data:
return self.extract_vertices_from_gltf(json_data, binary_data)
except Exception as e:
print(f"解析GLB数据失败: {e}")
return None
def extract_vertices_from_gltf(self, gltf_json, binary_data):
"""从glTF JSON中提取顶点数据"""
vertices = []
try:
if 'extensionsUsed' in gltf_json and 'KHR_draco_mesh_compression' in gltf_json['extensionsUsed']:
if DracoPy is None:
print("警告: 检测到Draco压缩但DracoPy未安装")
return vertices
return self.extract_draco_vertices(gltf_json, binary_data)
if 'meshes' not in gltf_json:
return vertices
for mesh in gltf_json['meshes']:
for primitive in mesh['primitives']:
if 'attributes' in primitive and 'POSITION' in primitive['attributes']:
position_accessor_index = primitive['attributes']['POSITION']
if 'accessors' in gltf_json and position_accessor_index < len(gltf_json['accessors']):
accessor = gltf_json['accessors'][position_accessor_index]
buffer_view_index = accessor['bufferView']
if 'bufferViews' in gltf_json and buffer_view_index < len(gltf_json['bufferViews']):
buffer_view = gltf_json['bufferViews'][buffer_view_index]
buffer_index = buffer_view['buffer']
byte_offset = buffer_view.get('byteOffset', 0) + accessor.get('byteOffset', 0)
if binary_data and buffer_index == 0:
component_type = accessor['componentType']
count = accessor['count']
if component_type == 5126: # FLOAT
vertex_data = struct.unpack(f'<{count*3}f',
binary_data[byte_offset:byte_offset+count*12])
for i in range(0, len(vertex_data), 3):
vertices.append([vertex_data[i], vertex_data[i+1], vertex_data[i+2]])
except Exception as e:
print(f"提取顶点数据失败: {e}")
return vertices
def extract_draco_vertices(self, gltf_json, binary_data):
"""提取Draco压缩的顶点数据"""
vertices = []
try:
if 'meshes' not in gltf_json:
return vertices
for mesh in gltf_json['meshes']:
for primitive in mesh['primitives']:
if 'extensions' in primitive and 'KHR_draco_mesh_compression' in primitive['extensions']:
draco_ext = primitive['extensions']['KHR_draco_mesh_compression']
buffer_view_index = draco_ext['bufferView']
if 'bufferViews' in gltf_json and buffer_view_index < len(gltf_json['bufferViews']):
buffer_view = gltf_json['bufferViews'][buffer_view_index]
byte_offset = buffer_view.get('byteOffset', 0)
byte_length = buffer_view['byteLength']
if binary_data:
draco_data = binary_data[byte_offset:byte_offset+byte_length]
mesh_data = DracoPy.decode(draco_data)
if hasattr(mesh_data, 'points'):
points = mesh_data.points
for point in points:
vertices.append([float(point[0]), float(point[1]), float(point[2])])
except Exception as e:
print(f"解码Draco数据失败: {e}")
return vertices
def calculate_triangle_angles(self, p1, p2, p3):
"""计算三角形的三个内角(度)"""
# 计算三边长度
a = np.linalg.norm(p2 - p3) # 边a对应角A(p1)
b = np.linalg.norm(p1 - p3) # 边b对应角B(p2)
c = np.linalg.norm(p1 - p2) # 边c对应角C(p3)
# 避免除零错误
if a == 0 or b == 0 or c == 0:
return [0, 0, 0]
# 使用余弦定理计算角度
try:
# 角A = arccos((b²+c²-a²)/(2bc))
cos_A = (b*b + c*c - a*a) / (2*b*c)
cos_B = (a*a + c*c - b*b) / (2*a*c)
cos_C = (a*a + b*b - c*c) / (2*a*b)
# 限制余弦值范围,避免数值误差
cos_A = np.clip(cos_A, -1.0, 1.0)
cos_B = np.clip(cos_B, -1.0, 1.0)
cos_C = np.clip(cos_C, -1.0, 1.0)
angle_A = np.arccos(cos_A) * 180 / np.pi
angle_B = np.arccos(cos_B) * 180 / np.pi
angle_C = np.arccos(cos_C) * 180 / np.pi
return [angle_A, angle_B, angle_C]
except:
return [0, 0, 0]
def is_valid_triangle(self, p1, p2, p3, min_angle=10.0, max_aspect_ratio=10.0):
"""验证三角形质量,基于角度约束和长宽比"""
angles = self.calculate_triangle_angles(p1, p2, p3)
# 检查最小角度约束参考C#代码中的10度限制
if min(angles) < min_angle:
return False
# 计算三边长度
a = np.linalg.norm(p2 - p3)
b = np.linalg.norm(p1 - p3)
c = np.linalg.norm(p1 - p2)
# 检查长宽比约束
max_side = max(a, b, c)
min_side = min(a, b, c)
if min_side > 0 and max_side / min_side > max_aspect_ratio:
return False
return True
def calculate_circumcenter_and_radius(self, p1, p2, p3):
"""计算三角形外接圆圆心和半径(高精度算法)"""
try:
x1, y1 = p1[0], p1[1]
x2, y2 = p2[0], p2[1]
x3, y3 = p3[0], p3[1]
# 使用C#代码中的高精度外接圆计算公式
d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2))
if abs(d) < 1e-10: # 三点共线
return None, float('inf')
ux = ((x1*x1 + y1*y1) * (y2 - y3) + (x2*x2 + y2*y2) * (y3 - y1) + (x3*x3 + y3*y3) * (y1 - y2)) / d
uy = ((x1*x1 + y1*y1) * (x3 - x2) + (x2*x2 + y2*y2) * (x1 - y3) + (x3*x3 + y3*y3) * (x2 - x1)) / d
# 计算半径
radius = np.sqrt((ux - x1)**2 + (uy - y1)**2)
return np.array([ux, uy]), radius
except:
return None, float('inf')
def calculate_volume_delaunay(self, vertices, base_elevation=None, min_angle=10.0, use_quality_filter=True):
"""使用Delaunay三角剖分计算体积"""
if len(vertices) < 4:
print("顶点数量不足,无法进行三角剖分")
return 0.0
try:
points = np.array(vertices)
if base_elevation is None:
base_elevation = np.min(points[:, 2])
print(f"Delaunay方法使用基准面高程: {base_elevation:.2f}")
print(f"质量控制参数: 最小角度={min_angle}°, 质量过滤={'开启' if use_quality_filter else '关闭'}")
adjusted_points = points.copy()
adjusted_points[:, 2] = np.maximum(points[:, 2] - base_elevation, 0)
print("正在进行Delaunay三角剖分...")
tri = Delaunay(adjusted_points)
valid_simplices = []
total_simplices = len(tri.simplices)
if use_quality_filter:
print("正在进行三角形质量检查...")
for simplex in tqdm(tri.simplices, desc="质量检查", unit=""):
p0 = adjusted_points[simplex[0]]
p1 = adjusted_points[simplex[1]]
p2 = adjusted_points[simplex[2]]
p3 = adjusted_points[simplex[3]]
faces = [(p0, p1, p2), (p0, p1, p3), (p0, p2, p3), (p1, p2, p3)]
valid_faces = 0
for face in faces:
if self.is_valid_triangle(face[0], face[1], face[2], min_angle):
valid_faces += 1
if valid_faces >= 3:
valid_simplices.append(simplex)
print(f"质量过滤: {len(valid_simplices)}/{total_simplices} 个四面体通过质量检查")
else:
valid_simplices = tri.simplices
total_volume = 0.0
valid_volume_count = 0
print(f"计算 {len(valid_simplices)} 个四面体的体积...")
for simplex in tqdm(valid_simplices, desc="计算四面体体积", unit=""):
p0 = adjusted_points[simplex[0]]
p1 = adjusted_points[simplex[1]]
p2 = adjusted_points[simplex[2]]
p3 = adjusted_points[simplex[3]]
v1 = p1 - p0
v2 = p2 - p0
v3 = p3 - p0
det = np.linalg.det(np.array([v1, v2, v3]))
volume = abs(det) / 6.0
if volume > 1e-12:
total_volume += volume
valid_volume_count += 1
print(f"有效体积计算: {valid_volume_count}/{len(valid_simplices)} 个四面体")
return total_volume
except Exception as e:
print(f"Delaunay三角剖分计算体积失败: {e}")
return 0.0
def load_and_filter_vertices(self, tileset_path):
"""加载并过滤指定范围内的顶点"""
print(f"开始处理tileset: {tileset_path}")
# 解析tileset获取所有B3DM文件
b3dm_files = self.parse_tileset_json(tileset_path)
print(f"找到 {len(b3dm_files)} 个B3DM文件")
if not b3dm_files:
print("未找到任何B3DM文件")
return False
# 处理每个B3DM文件
processed_count = 0
total_vertices = 0
filtered_count = 0
for i, (b3dm_file, transform_matrix) in enumerate(tqdm(b3dm_files, desc="处理B3DM文件", unit="文件")):
# print(f"处理文件 {i+1}/{len(b3dm_files)}: {os.path.basename(b3dm_file)}")
vertices = self.parse_b3dm_file(b3dm_file)
if vertices:
# 应用变换矩阵
if transform_matrix:
vertices = self.apply_transform_to_vertices(vertices, transform_matrix)
# 过滤范围内的顶点
for vertex in vertices:
total_vertices += 1
if self.is_point_in_bounds(vertex):
self.filtered_vertices.append(vertex)
filtered_count += 1
self.all_vertices.extend(vertices)
processed_count += 1
# tqdm.write(f" {os.path.basename(b3dm_file)}: 提取到 {len(vertices)} 个顶点")
print(f"\n成功处理 {processed_count}/{len(b3dm_files)} 个文件")
print(f"总计提取 {total_vertices} 个顶点")
print(f"范围内顶点 {filtered_count}")
return len(self.filtered_vertices) > 0
def calculate_volume(self, tileset_path, base_elevation=None, min_angle=10.0, use_quality_filter=True):
"""计算指定范围内三维模型的体积
Args:
tileset_path: tileset.json文件路径
base_elevation: 基准面高程
min_angle: 最小角度约束
use_quality_filter: 是否启用质量过滤
"""
if not self.load_and_filter_vertices(tileset_path):
print("未找到范围内的顶点数据")
return 0.0
print(f"\n开始计算体积使用Delaunay三角剖分方法")
print(f"参与计算的顶点数: {len(self.filtered_vertices)}")
points = np.array(self.filtered_vertices)
if base_elevation is None:
base_elevation = np.min(points[:, 2])
print(f"统一基准面高程: {base_elevation:.2f}")
volume = self.calculate_volume_delaunay(self.filtered_vertices, base_elevation, min_angle, use_quality_filter)
print(f"\n计算结果:")
print(f"体积: {volume:.6f} 立方米")
print(f"体积: {volume/1000000:.6f} 立方千米")
return volume
def main():
if len(sys.argv) < 3:
print("用法: python volume_calculator.py <lboundary.json路径> <tileset.json路径> [基准面高程] [最小角度] [质量过滤]")
print("基准面高程: 基准面高程(米),默认使用最低点")
print("最小角度: 三角形最小角度约束(度)默认10.0")
print("质量过滤: 是否启用质量过滤(true/false)默认true")
print("示例: python volume_calculator.py boundary.json tileset.json 100.0 15.0 true")
return
location_file = sys.argv[1]
tileset_path = sys.argv[2]
# 解析可选参数
base_elevation = None
min_angle = 10.0
use_quality_filter = True
if len(sys.argv) > 3:
try:
base_elevation = float(sys.argv[3])
except ValueError:
print("错误:基准面高程必须是数字")
return
if len(sys.argv) > 4:
try:
min_angle = float(sys.argv[4])
except ValueError:
print("错误:最小角度必须是数字")
return
if len(sys.argv) > 5:
use_quality_filter = sys.argv[5].lower() in ['true', '1', 'yes', 'on']
if not os.path.exists(location_file):
print(f"错误: 位置文件不存在 {location_file}")
return
if not os.path.exists(tileset_path):
print(f"错误: tileset文件不存在 {tileset_path}")
return
calculator = VolumeCalculator(location_file)
volume = calculator.calculate_volume(tileset_path, base_elevation, min_angle, use_quality_filter)
if volume > 0:
print("\n体积计算完成!")
else:
print("\n体积计算失败!")
if __name__ == "__main__":
main()