--- title: "酒店场景建模项目 - 数据格式规范(续)" date: 2026-05-20 draft: false tags: ["iPhone", "3D 重建", "RoomPlan", "物理", "酒店场景"] categories: ["worldmodel"] --- # 酒店场景建模项目 - 数据格式规范(续) > 本文档是 [`data_format_specification.md`](data_format_specification.md) 的续篇 --- ## 五、物理数据格式(续) ### 5.3 材质物理属性(续) #### material_props.json(完整示例) ```json { "version": "1.0", "materials": [ { "material_id": "wood_oak", "name": "Oak Wood", "density": 600.0, "static_friction": 0.5, "dynamic_friction": 0.4, "restitution": 0.3, "young_modulus": 11000000000.0, "poisson_ratio": 0.3, "damping": 0.1 }, { "material_id": "fabric_cotton", "name": "Cotton Fabric", "density": 200.0, "static_friction": 0.7, "dynamic_friction": 0.6, "restitution": 0.1, "young_modulus": 1000000.0, "poisson_ratio": 0.4, "damping": 0.5 }, { "material_id": "glass", "name": "Glass", "density": 2500.0, "static_friction": 0.4, "dynamic_friction": 0.3, "restitution": 0.8, "young_modulus": 70000000000.0, "poisson_ratio": 0.24, "damping": 0.01, "transparency": 0.9, "ior": 1.52 }, { "material_id": "metal_steel", "name": "Steel", "density": 7800.0, "static_friction": 0.6, "dynamic_friction": 0.5, "restitution": 0.5, "young_modulus": 200000000000.0, "poisson_ratio": 0.3, "damping": 0.05 } ] } ``` --- ## 六、元数据格式 ### 6.1 全局元数据 #### metadata.json ```json { "dataset_name": "HotelScene-Dataset", "version": "1.0.0", "creation_date": "2026-05-16", "description": "High-fidelity digital twin dataset for hotel scenes", "license": "CC BY-NC 4.0", "citation": "@article{hotel2026, title={Hotel Scene Reconstruction}, ...}", "authors": [ { "name": "Zhang San", "affiliation": "University", "email": "zhangsan@university.edu", "orcid": "0000-0000-0000-0000" } ], "statistics": { "num_scenes": 10, "num_frames": 5000, "num_instances": 523, "total_size_gb": 750.5, "scene_types": { "lobby": 2, "corridor": 2, "room": 5, "bathroom": 5 } }, "coordinate_system": { "type": "right_hand", "up_axis": "Z", "forward_axis": "X", "unit": "meter" }, "sensors": { "lidar": { "model": "Livox Mid-360", "frequency_hz": 10, "range_m": 70, "accuracy_m": 0.02, "fov_deg": 360 }, "camera_rgb": { "model": "Azure Kinect DK", "resolution": [1920, 1080], "fps": 30, "fov_deg": 90, "sensor_size_mm": [6.4, 4.8] }, "camera_depth": { "model": "Azure Kinect DK ToF", "resolution": [640, 576], "fps": 30, "range_m": [0.25, 5.46], "accuracy_m": 0.01 }, "imu": { "model": "Xsens MTi-630", "frequency_hz": 400, "accel_range_g": 16, "gyro_range_dps": 2000 } }, "processing_info": { "slam_method": "FAST-LIO2", "reconstruction_method": "3D Gaussian Splatting", "semantic_method": "YOLO-World + SAM + CLIP", "software_versions": { "python": "3.10.12", "pytorch": "2.1.0", "open3d": "0.18.0", "colmap": "3.8" } } } ``` ### 6.2 场景元数据 #### scene_info.json(完整示例) ```json { "scene_id": "room_301", "scene_type": "hotel_room", "hotel_info": { "name": "Grand Hotel", "address": "123 Main St, Shanghai", "star_rating": 5, "floor": 3, "room_number": "301", "room_type": "deluxe" }, "capture_info": { "date": "2026-05-16", "time_start": "10:30:00", "time_end": "10:50:00", "duration_minutes": 20, "operator": "Zhang San", "device": "iPhone 15 Pro Max", "weather": "sunny", "lighting_condition": "natural + artificial", "temperature_celsius": 22, "humidity_percent": 45 }, "geometry": { "floor_area_sqm": 25.5, "ceiling_height_m": 2.8, "bbox_min": [0.0, 0.0, 0.0], "bbox_max": [5.0, 5.1, 2.8], "volume_m3": 71.4, "wall_thickness_m": 0.2 }, "statistics": { "num_frames": 500, "num_rgb_images": 500, "num_depth_images": 500, "num_lidar_scans": 200, "num_points_raw": 12345678, "num_points_processed": 5432109, "num_instances": 23, "trajectory_length_m": 15.2, "scan_coverage_sqm": 24.3 }, "quality_metrics": { "slam": { "loop_closure_error_m": 0.003, "trajectory_rmse_m": 0.015, "num_loop_closures": 5 }, "point_cloud": { "density_points_per_sqm": 5000, "coverage_percentage": 95.2, "noise_std_m": 0.018 }, "reconstruction": { "psnr_db": 28.5, "ssim": 0.87, "lpips": 0.14 }, "semantic": { "detection_map_50": 0.72, "instance_iou": 0.68, "scene_graph_completeness": 0.85 } }, "processing_status": { "data_collection": { "status": "completed", "timestamp": "2026-05-16T10:50:00Z" }, "slam": { "status": "completed", "timestamp": "2026-05-16T12:30:00Z", "duration_minutes": 45 }, "3dgs": { "status": "completed", "timestamp": "2026-05-16T18:00:00Z", "duration_minutes": 180, "iterations": 30000 }, "semantic": { "status": "completed", "timestamp": "2026-05-16T19:30:00Z", "duration_minutes": 60 }, "physics": { "status": "in_progress", "timestamp": null, "progress_percent": 45 } }, "notes": "High-quality scan with good lighting. Minor occlusion behind wardrobe.", "tags": ["deluxe_room", "high_quality", "complete_coverage"] } ``` --- ## 七、数据交换与压缩 ### 7.1 跨平台交换格式优先级 ```yaml 点云格式: 首选: PLY (binary) - 优势: 通用性最好,所有工具支持 - 劣势: 文件较大 备选: PCD (binary) - 优势: Open3D原生格式 - 劣势: 部分工具不支持 大规模: LAS/LAZ - 优势: 压缩率高(50-80%) - 劣势: 需要专门库 网格格式: 首选: OBJ + MTL - 优势: 最通用,人类可读 - 劣势: 文件较大,不支持动画 Web: GLTF/GLB - 优势: Web友好,支持PBR - 劣势: 桌面工具支持有限 游戏: FBX - 优势: Unity/Unreal原生 - 劣势: 专有格式 物理: USD/USDZ - 优势: 支持物理属性 - 劣势: 学习曲线陡峭 图像格式: 无损: PNG 有损: JPEG (quality=95) HDR: EXR 深度格式: 标准: 16-bit PNG 高精度: 32-bit EXR 数组: NPY (NumPy) 语义格式: 人类可读: JSON 大规模: HDF5 高效传输: Protocol Buffers ``` ### 7.2 数据压缩策略 #### 点云压缩 ```python import open3d as o3d import numpy as np def compress_pointcloud(input_ply, output_ply, voxel_size=0.01): """体素下采样压缩点云""" pcd = o3d.io.read_point_cloud(input_ply) # 体素下采样 pcd_down = pcd.voxel_down_sample(voxel_size) # 保存 o3d.io.write_point_cloud(output_ply, pcd_down, write_ascii=False) # 统计 original_size = len(pcd.points) compressed_size = len(pcd_down.points) ratio = compressed_size / original_size print(f"Compression ratio: {ratio:.2%}") print(f"Points: {original_size} → {compressed_size}") # LAZ压缩(需要laspy库) import laspy def compress_to_laz(points, colors, output_laz): """压缩为LAZ格式""" header = laspy.LasHeader(point_format=3, version="1.4") header.offsets = np.min(points, axis=0) header.scales = np.array([0.001, 0.001, 0.001]) las = laspy.LasData(header) las.x = points[:, 0] las.y = points[:, 1] las.z = points[:, 2] las.red = (colors[:, 0] * 65535).astype(np.uint16) las.green = (colors[:, 1] * 65535).astype(np.uint16) las.blue = (colors[:, 2] * 65535).astype(np.uint16) las.write(output_laz) ``` #### 数据集打包 ```bash # 场景级打包 tar -czf room_301.tar.gz room_301/ # 分卷压缩(大文件) tar -czf - room_301/ | split -b 1G - room_301.tar.gz.part # 解压分卷 cat room_301.tar.gz.part* | tar -xzf - # 7z高压缩率 7z a -t7z -m0=lzma2 -mx=9 room_301.7z room_301/ ``` ### 7.3 HDF5大规模数据格式 ```python import h5py import numpy as np def save_scene_hdf5(filepath, scene_data): """保存场景到HDF5(完整示例)""" with h5py.File(filepath, 'w') as f: # 元数据(属性) f.attrs['scene_id'] = scene_data['scene_id'] f.attrs['version'] = '1.0' f.attrs['creation_date'] = '2026-05-16' # 点云组 pc_group = f.create_group('point_cloud') pc_group.create_dataset( 'points', data=scene_data['points'], compression='gzip', compression_opts=9 ) pc_group.create_dataset('colors', data=scene_data['colors']) pc_group.create_dataset('normals', data=scene_data['normals']) pc_group.attrs['num_points'] = len(scene_data['points']) # 轨迹组 traj_group = f.create_group('trajectory') traj_group.create_dataset('timestamps', data=scene_data['timestamps']) traj_group.create_dataset('poses', data=scene_data['poses']) traj_group.attrs['num_poses'] = len(scene_data['poses']) # 图像组(可选,大数据) if 'images' in scene_data: img_group = f.create_group('images') for i, img in enumerate(scene_data['images']): img_group.create_dataset( f'frame_{i:07d}', data=img, compression='gzip' ) # 语义组 sem_group = f.create_group('semantic') sem_group.create_dataset('instance_ids', data=scene_data['instance_ids']) sem_group.create_dataset('features', data=scene_data['features']) # 场景图(JSON字符串) if 'scene_graph' in scene_data: import json sg_json = json.dumps(scene_data['scene_graph']) f.create_dataset('scene_graph_json', data=sg_json) def load_scene_hdf5(filepath): """从HDF5加载场景""" scene_data = {} with h5py.File(filepath, 'r') as f: # 元数据 scene_data['scene_id'] = f.attrs['scene_id'] scene_data['version'] = f.attrs['version'] # 点云 scene_data['points'] = f['point_cloud/points'][:] scene_data['colors'] = f['point_cloud/colors'][:] scene_data['normals'] = f['point_cloud/normals'][:] # 轨迹 scene_data['timestamps'] = f['trajectory/timestamps'][:] scene_data['poses'] = f['trajectory/poses'][:] # 语义 scene_data['instance_ids'] = f['semantic/instance_ids'][:] scene_data['features'] = f['semantic/features'][:] # 场景图 if 'scene_graph_json' in f: import json sg_json = f['scene_graph_json'][()] if isinstance(sg_json, bytes): sg_json = sg_json.decode('utf-8') scene_data['scene_graph'] = json.loads(sg_json) return scene_data # 流式读取(大数据) def stream_images_hdf5(filepath): """流式读取图像""" with h5py.File(filepath, 'r') as f: img_group = f['images'] for key in sorted(img_group.keys()): yield img_group[key][:] ``` --- ## 八、数据验证与质量控制 ### 8.1 完整性检查清单 ```yaml 必需文件检查: - scene_info.json - raw/timestamps.txt - processed/slam/trajectory.txt - processed/slam/dense_map.ply 可选文件检查: - processed/3dgs/input.ply - processed/mesh/scene.obj - processed/semantic/scene_graph.json - processed/physics/scene.usd 数据一致性检查: - RGB帧数 == 深度帧数 - 时间戳数量 == 帧数 - 轨迹长度 == 帧数 - 实例ID连续性 元数据完整性: - 所有必需字段存在 - 数值范围合理 - 时间戳格式正确 - 坐标系定义明确 质量指标检查: - SLAM误差 < 0.5% - 点云密度 > 1000 points/m² - 覆盖率 > 90% - PSNR > 25 dB ``` ### 8.2 自动化验证脚本 ```python import os import json from pathlib import Path from typing import List, Dict class DatasetValidator: """数据集完整性验证器""" def __init__(self, dataset_root: str): self.root = Path(dataset_root) self.errors = [] self.warnings = [] self.info = [] def validate_dataset(self) -> Dict: """验证整个数据集""" # 1. 检查全局元数据 self._check_global_metadata() # 2. 检查所有场景 scenes_dir = self.root / 'scenes' if scenes_dir.exists(): for scene_dir in scenes_dir.iterdir(): if scene_dir.is_dir(): self.validate_scene(scene_dir) else: self.errors.append("Missing scenes directory") # 3. 生成报告 return self.generate_report() def validate_scene(self, scene_path: Path): """验证单个场景""" scene_id = scene_path.name self.info.append(f"Validating scene: {scene_id}") # 必需文件检查 required_files = { 'scene_info.json': 'Scene metadata', 'raw/timestamps.txt': 'Timestamp alignment', 'processed/slam/trajectory.txt': 'SLAM trajectory' } for file, desc in required_files.items(): if not (scene_path / file).exists(): self.errors.append(f"{scene_id}: Missing {desc} ({file})") # 数据一致性检查 self._check_frame_consistency(scene_path, scene_id) # 元数据检查 self._check_scene_metadata(scene_path, scene_id) # 质量指标检查 self._check_quality_metrics(scene_path, scene_id) def _check_global_metadata(self): """检查全局元数据""" metadata_file = self.root / 'metadata.json' if not metadata_file.exists(): self.errors.append("Missing global metadata.json") return with open(metadata_file) as f: metadata = json.load(f) required_fields = ['dataset_name', 'version', 'license'] for field in required_fields: if field not in metadata: self.errors.append(f"Missing metadata field: {field}") def _check_frame_consistency(self, scene_path: Path, scene_id: str): """检查帧数一致性""" rgb_dir = scene_path / 'raw/rgb' depth_dir = scene_path / 'raw/depth' if rgb_dir.exists() and depth_dir.exists(): rgb_files = sorted(rgb_dir.glob('*.jpg')) depth_files = sorted(depth_dir.glob('*.png')) if len(rgb_files) != len(depth_files): self.warnings.append( f"{scene_id}: Frame count mismatch - " f"RGB={len(rgb_files)}, Depth={len(depth_files)}" ) # 检查文件名连续性 for i, (rgb_file, depth_file) in enumerate(zip(rgb_files, depth_files)): expected_name = f"{i:07d}" if rgb_file.stem != expected_name: self.warnings.append( f"{scene_id}: RGB frame naming issue at {i}" ) if depth_file.stem != expected_name: self.warnings.append( f"{scene_id}: Depth frame naming issue at {i}" ) def _check_scene_metadata(self, scene_path: Path, scene_id: str): """检查场景元数据""" info_file = scene_path / 'scene_info.json' if not info_file.exists(): return with open(info_file) as f: info = json.load(f) # 必需字段 required_fields = ['scene_id', 'scene_type', 'capture_info', 'geometry'] for field in required_fields: if field not in info: self.errors.append(f"{scene_id}: Missing metadata field '{field}'") # 检查scene_id一致性 if info.get('scene_id') != scene_id: self.warnings.append( f"{scene_id}: scene_id mismatch in metadata " f"(expected: {scene_id}, got: {info.get('scene_id')})" ) def _check_quality_metrics(self, scene_path: Path, scene_id: str): """检查质量指标""" info_file = scene_path / 'scene_info.json' if not info_file.exists(): return with open(info_file) as f: info = json.load(f) if 'quality_metrics' not in info: self.warnings.append(f"{scene_id}: Missing quality metrics") return metrics = info['quality_metrics'] # SLAM质量 if 'slam' in metrics: slam = metrics['slam'] if slam.get('loop_closure_error_m', 1.0) > 0.01: self.warnings.append( f"{scene_id}: High SLAM loop closure error " f"({slam.get('loop_closure_error_m'):.4f}m)" ) # 点云质量 if 'point_cloud' in metrics: pc = metrics['point_cloud'] if pc.get('coverage_percentage', 0) < 90: self.warnings.append( f"{scene_id}: Low coverage " f"({pc.get('coverage_percentage'):.1f}%)" ) if pc.get('density_points_per_sqm', 0) < 1000: self.warnings.append( f"{scene_id}: Low point cloud density " f"({pc.get('density_points_per_sqm')} points/m²)" ) # 渲染质量 if 'reconstruction' in metrics: recon = metrics['reconstruction'] if recon.get('psnr_db', 0) < 25: self.warnings.append( f"{scene_id}: Low PSNR ({recon.get('psnr_db'):.1f} dB)" ) def generate_report(self) -> Dict: """生成验证报告""" report = { 'status': 'PASS' if len(self.errors) == 0 else 'FAIL', 'summary': { 'total_errors': len(self.errors), 'total_warnings': len(self.warnings), 'total_info': len(self.info) }, 'errors': self.errors, 'warnings': self.warnings, 'info': self.info } return report def print_report(self): """打印报告""" report = self.generate_report() print("=" * 60) print("DATASET VALIDATION REPORT") print("=" * 60) print(f"Status: {report['status']}") print(f"Errors: {report['summary']['total_errors']}") print(f"Warnings: {report['summary']['total_warnings']}") print() if report['errors']: print("ERRORS:") for error in report['errors']: print(f" ❌ {error}") print() if report['warnings']: print("WARNINGS:") for warning in report['warnings']: print(f" ⚠️ {warning}") print() print("=" * 60) # 使用示例 validator = DatasetValidator('hotel_dataset') validator.validate_dataset() validator.print_report() ``` --- ## 九、数据加载工具库 ### 9.1 完整的Python加载器 ```python import json import numpy as np import open3d as o3d import cv2 from pathlib import Path from typing import Dict, List, Optional, Tuple from dataclasses import dataclass @dataclass class SceneMetadata: """场景元数据""" scene_id: str scene_type: str floor_area: float ceiling_height: float num_frames: int num_instances: int class HotelSceneLoader: """酒店场景数据加载器""" def __init__(self, dataset_root: str): self.root = Path(dataset_root) self._load_global_metadata() def _load_global_metadata(self): """加载全局元数据""" metadata_file = self.root / 'metadata.json' if metadata_file.exists(): with open(metadata_file) as f: self.global_metadata = json.load(f) else: self.global_metadata = {} def list_scenes(self) -> List[str]: """列出所有场景""" scenes_dir = self.root / 'scenes' return [d.name for d in scenes_dir.iterdir() if d.is_dir()] def load_scene_metadata(self, scene_id: str) -> SceneMetadata: """加载场景元数据""" info_file = self.root / 'scenes' / scene_id / 'scene_info.json' with open(info_file) as f: info = json.load(f) return SceneMetadata( scene_id=info['scene_id'], scene_type=info['scene_type'], floor_area=info['geometry']['floor_area_sqm'], ceiling_height=info['geometry']['ceiling_height_m'], num_frames=info['statistics']['num_frames'], num_instances=info['statistics']['num_instances'] ) def load_point_cloud(self, scene_id: str, cloud_type: str = 'dense') -> o3d.geometry.PointCloud: """ 加载点云 Args: scene_id: 场景ID cloud_type: 'sparse' 或 'dense' """ scene_path = self.root / 'scenes' / scene_id if cloud_type == 'dense': pc_file = scene_path / 'processed/slam/dense_map.ply' else: pc_file = scene_path / 'processed/slam/sparse_map.ply' if pc_file.exists(): return o3d.io.read_point_cloud(str(pc_file)) return None def load_trajectory(self, scene_id: str) -> Tuple[np.ndarray, np.ndarray]: """ 加载轨迹 Returns: timestamps: (N,) 时间戳 poses: (N, 4, 4) 位姿矩阵 """ traj_file = self.root / 'scenes' / scene_id / 'processed/slam/trajectory.txt' if not traj_file.exists(): return None, None data = np.loadtxt(traj_file) timestamps = data[:, 0] # 转换为4x4矩阵 from scipy.spatial.transform import Rotation positions = data[:, 1:4] quaternions = data[:, 4:8] rotations = Rotation.from_quat(quaternions).as_matrix() poses = np.zeros((len(data), 4, 4)) poses[:, :3, :3] = rotations poses[:, :3, 3] = positions poses[:, 3, 3] = 1.0 return timestamps, poses def load_mesh(self, scene_id: str) -> o3d.geometry.TriangleMesh: """加载网格模型""" mesh_file = self.root / 'scenes' / scene_id / 'processed/mesh/scene.obj' if mesh_file.exists(): return o3d.io