#!/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(' len(glb_data): break chunk_length = struct.unpack(' 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 [基准面高程] [最小角度] [质量过滤]") 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()