--- title: "Chapter 04 — 管线 A:iPhone 离线建图" date: 2026-05-20 draft: false tags: ["PRISM", "世界模型", "空间记忆", "点云", "Gaussian Splatting", "iOS"] categories: ["PRISM"] --- # Chapter 04 — 管线 A:iPhone 离线建图 > 本章目标:把 iPhone Pro 扫描得到的 **USDZ / RoomPlan JSON / 原始 ARKit 数据**,转换为 PRISM 的 **`SpatialMemory` 长期记忆 LTM**,包括 L2 度量 + L3 拓扑 + L4 语义。 --- ## 4.1 管线总览 ```mermaid flowchart LR A["📱 iPhone Pro
RoomPlan + ARKit RAW
1. 采集"] B["💻 Mac / Linux
上传 / 下载
2. 传输"] C["🐍 Parser & Glue
(Python)
3. 解析 + 几何处理"] D[("🧠 PRISM LTM
写入
4. 落盘")] A --> B --> C --> D style A fill:#e3f2fd,stroke:#1565c0 style D fill:#fff7d6,stroke:#c97a00 ``` 5 个阶段: | 阶段 | 工具 | 输入 | 输出 | |------|------|------|------| | A1 采集 | Swift App (RoomPlan + ARKit) | 人手持 iPhone | `Hotel.usdz` + `roomplan.json` + ARKit raw | | A2 传输 | scp / iCloud / WebDAV | iPhone → Mac/PC | 同上 | | A3 坐标对齐 | Python + ArUco | iPhone session 坐标 | `T_iphone→map` | | A4 解析 | `parser_iphone.py` | RoomPlan JSON | `SpatialNode/Edge` 列表 | | A5 几何派生 | Open3D / nvblox | mesh + 点云 | OctoMap + TSDF + 3DGS | --- ## 4.2 阶段 A1:iPhone 端采集 ### 4.2.1 推荐的 Swift App 骨架 ```swift // PRISMScanner/RoomScannerView.swift import RoomPlan import ARKit class RoomScannerCoordinator: NSObject, RoomCaptureSessionDelegate { let captureSession = RoomCaptureSession() let arSession = ARSession() var capturedRoom: CapturedRoom? var capturedFrames: [ARFrame] = [] // 同步保留原始帧 func start() { // 1) RoomPlan 高层结构 captureSession.delegate = self captureSession.run(configuration: .init()) // 2) ARKit 原始数据(深度 + RGB + 位姿)—— 单独存 let cfg = ARWorldTrackingConfiguration() cfg.frameSemantics.insert(.sceneDepth) cfg.frameSemantics.insert(.smoothedSceneDepth) arSession.delegate = self arSession.run(cfg) } func captureSession(_ session: RoomCaptureSession, didEndWith data: CapturedRoomData, error: Error?) { Task { let room = try await RoomBuilder().capturedRoom(from: data) try export(room, frames: capturedFrames) } } private func export(_ room: CapturedRoom, frames: [ARFrame]) throws { // (a) RoomPlan 结构化输出 try room.export(to: docsURL.appendingPathComponent("Hotel.usdz")) let json = try JSONEncoder().encode(room) try json.write(to: docsURL.appendingPathComponent("roomplan.json")) // (b) ARKit 原始 → 给 Python 端的稠密重建用 try ARKitDumper.dump(frames, to: docsURL.appendingPathComponent("arkit/")) } } ``` ### 4.2.2 采集 SOP(标准作业流程) | 步骤 | 时间 | 关键动作 | |------|------|----------| | 1. 环境准备 | 2 min | 开灯、移除人/宠物、关闭电视 | | 2. **放置 ArUco 标识** | 1 min | 在地面放 1 个 30 cm × 30 cm ArUco/AprilTag(用于后续 `map` 原点对齐,见 4.4) | | 3. App 启动 | 30 s | 检查 LiDAR 工作正常(预览有点云) | | 4. 房间扫描 | 10–15 min | 沿墙慢走,距墙 1–1.5 m,速度 < 0.3 m/s | | 5. 重点区域回扫 | 5 min | 床、桌、衣柜(开门)、卫生间门口 | | 6. 走廊连接 | 3 min/段 | 同一 ARKit session 内穿过门,保证多房间共享坐标系 | | 7. 结束 + 命名 | 1 min | 输出 `Hotel-3F.usdz` 等 | ### 4.2.3 多房间扫描的两种策略 | 策略 | 适用 | 优点 | 缺点 | |------|------|------|------| | **单 Session 连扫** | < 5 房间,路径连续 | 自动共享坐标系,无需配准 | 长时漂移大 | | **多 Session 分扫 + ArUco 拼接** | ≥ 5 房间或不连通 | 每房间独立精度高 | 需手工拼接(见 4.4) | > 经验:酒店一层楼建议 **每 3 个房间 + 中间走廊** 作为 1 个 session,最多扫 4–5 个 session,最后用走廊的公共 ArUco 串起来。 --- ## 4.3 阶段 A2:数据传输 ```mermaid flowchart TB subgraph IOS["iPhone Files App
On My iPhone/PRISMScanner/"] F1["Hotel-3F.usdz
(4–20 MB)"] F2["roomplan.json
(~50 KB)"] subgraph ARK["arkit/"] A1["frames.h5
深度图序列, 200 MB ~ 2 GB"] A2["rgb/*.jpg"] A3["poses.tum"] end end TRANS["iCloud / scp over WiFi (Mac) / WebDAV"] REPO[("Project repo:
data/scans/2026-05-16_3F/")] IOS --> TRANS --> REPO style IOS fill:#e3f2fd,stroke:#1565c0 style REPO fill:#fff7d6,stroke:#c97a00 ``` **传输脚本**(在 Mac/Linux 跑): ```bash # scripts/fetch_scan.sh NAME=$1 # 2026-05-16_3F mkdir -p data/scans/$NAME # 通过 SSH 文件传输(需在 iPhone 上装 a-Shell 或类似 SSH 服务) scp -r mobile:Documents/PRISMScanner/$NAME/ data/scans/$NAME/ ls data/scans/$NAME/ ``` --- ## 4.4 阶段 A3:坐标系对齐 — 把 iPhone 锚到 `map` 帧 iPhone 每个 ARKit session 的原点是**第一帧时设备所在位置**,机器人却需要一个**稳定不变的世界原点 `map`**。 ### 4.4.1 公共原点策略(推荐) 在场景里放 1 个 **30 cm × 30 cm ArUco DICT_5X5_100 id=42** 标识,约定其**左上角**为 `map` 原点,**长边指 +x,短边指 +y,z 向上**。 iPhone 扫描时只要拍到这个标识就能算出 `T_iphone→map`: ```python # spatial_memory/align_to_map.py import cv2 import numpy as np from scipy.spatial.transform import Rotation as R ARUCO_SIZE_M = 0.30 ARUCO_DICT = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_5X5_100) TARGET_ID = 42 def find_T_iphone_to_map(rgb_jpg, intrinsics, arkit_pose) -> np.ndarray: """rgb_jpg: 某帧 RGB;arkit_pose: 该帧的 ARKit 位姿 T_cam→iphone_origin""" img = cv2.imread(rgb_jpg) corners, ids, _ = cv2.aruco.detectMarkers(img, ARUCO_DICT) if ids is None or TARGET_ID not in ids: return None idx = list(ids.flatten()).index(TARGET_ID) obj_pts = np.array([[0,0,0],[ARUCO_SIZE_M,0,0], [ARUCO_SIZE_M,ARUCO_SIZE_M,0], [0,ARUCO_SIZE_M,0]], dtype=np.float32) ok, rvec, tvec = cv2.solvePnP(obj_pts, corners[idx][0], intrinsics, None) T_cam_to_map = np.eye(4) T_cam_to_map[:3,:3] = cv2.Rodrigues(rvec)[0] T_cam_to_map[:3, 3] = tvec.flatten() T_cam_to_iphone = arkit_pose # T_iphone_to_map = T_cam_to_map @ inv(T_cam_to_iphone) return T_cam_to_map @ np.linalg.inv(T_cam_to_iphone) ``` 把 `T_iphone→map` 存进 `manifest.json`,后续所有几何都左乘这个变换。 ### 4.4.2 退化方案:无 ArUco 时 按约定:**第一次扫描的"门口正中、面朝房间内"**作为 `map` 原点;以后所有 session 用 ICP 拼到第一次。精度略低(±5 cm),但简单。 --- ## 4.5 阶段 A4:解析 RoomPlan → SpatialNode/Edge ### 4.5.1 RoomPlan JSON 结构(核心字段) ```json { "version": "iOS17", "story": { "floors": [{"identifier": "F3", "z_height": 0.0}], "walls": [ {"id": "w_001", "category": "Wall", "transform": [16 floats], "dimensions": [3.5, 2.7, 0.10]}, ... ], "openings": [ {"id": "o_001", "category": "Door", "transform": [...], "dimensions": [0.9, 2.1, 0.05], "connects": ["room_301","hallway_3F"]} ], "objects": [ {"id": "f_001", "category": "Bed", "transform": [...], "dimensions": [2.0, 1.8, 0.6], "confidence": "high"} ], "rooms": [ {"id": "room_301", "label": "Bedroom", "polygon": [[x,y],...]} ] } } ``` ### 4.5.2 解析器实现 ```python # spatial_memory/parser_iphone.py import json, numpy as np from .schema import (SpatialMemory, SpatialNode, SpatialEdge, Pose, MemoryLevel, Source) CATEGORY_MAP = { "Bed": ("bed", False), "Sofa": ("sofa", False), "Chair": ("chair", True), "Table": ("table", False), "Storage": ("storage", False), "TV": ("tv", False), "Toilet": ("toilet", False), "Sink": ("sink", False), "Bathtub": ("bathtub", False), "Refrigerator": ("fridge", False), "Stove": ("stove", False), "Dishwasher": ("dishwasher", False), "Oven": ("oven", False), "Washer": ("washer", False), "Fireplace": ("fireplace", False), "Stairs": ("stairs", False), } def parse_roomplan(json_path: str, T_iphone_to_map: np.ndarray, mesh_dir: str) -> SpatialMemory: mem = SpatialMemory() rp = json.load(open(json_path)) story = rp["story"] # ── 1. 房间节点(L3)── for room in story["rooms"]: polygon = np.array(room["polygon"], dtype=np.float32) center = polygon.mean(axis=0) node = SpatialNode( uid=f"room_{room['id']}", label=room.get("label", "Room"), level=MemoryLevel.L3, source=Source.IPHONE, confidence=0.95, pose=Pose(position=np.array([center[0], center[1], 0.0]), quaternion=np.array([1,0,0,0])), polygon_2d=polygon, category="room", attributes={"is_anchor": False}) mem.add_node(node) # ── 2. 墙节点(L2,不进场景图主查询,但保留供渲染/规划)── for wall in story["walls"]: T = transform_to_matrix(wall["transform"]) T_map = T_iphone_to_map @ T # 关键:左乘对齐 pos = T_map[:3, 3] quat = matrix_to_quat(T_map[:3, :3]) bbox = obb_from_transform_and_dims(T_map, wall["dimensions"]) node = SpatialNode( uid=f"wall_{wall['id']}", label="wall", category="structure", level=MemoryLevel.L2, source=Source.IPHONE, confidence=0.95, pose=Pose(position=pos, quaternion=quat), bbox_3d=bbox, attributes={"mobile": False, "no_update_zone": False}) mem.add_node(node) # ── 3. 门窗作为 L3 边的载体 ── for op in story["openings"]: if op["category"] not in ("Door", "Opening"): continue connects = op.get("connects", []) if len(connects) == 2: r1, r2 = f"room_{connects[0]}", f"room_{connects[1]}" if r1 in mem.nodes and r2 in mem.nodes: T = transform_to_matrix(op["transform"]) T_map = T_iphone_to_map @ T center_xyz = T_map[:3, 3] cost = 1.0 # 门,可通行 mem.add_edge(SpatialEdge( src_uid=r1, dst_uid=r2, relation="connects_to", weight=cost, source=Source.IPHONE)) # 也存为节点本身(可视化、关门状态等) mem.add_node(SpatialNode( uid=f"door_{op['id']}", label="door", category="opening", level=MemoryLevel.L2, source=Source.IPHONE, pose=Pose(position=center_xyz, quaternion=matrix_to_quat(T_map[:3,:3])), bbox_3d=obb_from_transform_and_dims(T_map, op["dimensions"]), confidence=0.9, attributes={"state":"closed", "mobile": False})) # ── 4. 家具节点(L4)── for obj in story["objects"]: cat = obj["category"] if cat not in CATEGORY_MAP: label, mobile = cat.lower(), True else: label, mobile = CATEGORY_MAP[cat] T = transform_to_matrix(obj["transform"]) T_map = T_iphone_to_map @ T # 找它所在房间(点-多边形) parent_room = find_room_for_point(T_map[:2, 3], mem) node = SpatialNode( uid=f"{label}_{obj['id']}", label=label, category="furniture", level=MemoryLevel.L4, source=Source.IPHONE, confidence=0.9 if obj.get("confidence")=="high" else 0.6, pose=Pose(position=T_map[:3,3], quaternion=matrix_to_quat(T_map[:3,:3])), bbox_3d=obb_from_transform_and_dims(T_map, obj["dimensions"]), parent_room=parent_room, mesh_uri=f"meshes/{label}_{obj['id']}.glb", # 4.6 会生成 attributes={"mobile": mobile, "is_anchor": (not mobile and label in ("bed","sofa","tv","toilet","bathtub","sink"))}) mem.add_node(node) if parent_room: mem.add_edge(SpatialEdge( src_uid=parent_room, dst_uid=node.uid, relation="contains", source=Source.IPHONE)) return mem ``` 辅助函数 `transform_to_matrix`、`matrix_to_quat`、`obb_from_transform_and_dims`、`find_room_for_point` 是标准几何工具,略。 --- ## 4.6 阶段 A5:几何派生(mesh → OctoMap/TSDF/3DGS + 切件 mesh) ### 4.6.1 从 USDZ 拆出每件家具的 mesh ```python # spatial_memory/extract_furniture_mesh.py from pxr import Usd, UsdGeom import trimesh import numpy as np def split_usdz_per_object(usdz_path: str, out_dir: str, mem: SpatialMemory) -> None: stage = Usd.Stage.Open(usdz_path) for prim in stage.Traverse(): if not UsdGeom.Mesh(prim): continue name = str(prim.GetPath()) # 根据 prim 名匹配到 SpatialNode uid = match_prim_to_uid(name, mem) if uid is None: continue # 提取顶点/面 → trimesh → 导出 .glb verts, faces = read_usd_mesh(prim) mesh = trimesh.Trimesh(vertices=verts, faces=faces) # 用 SpatialNode 的 inverse pose 把 mesh 移到局部坐标 T_map = mem.nodes[uid].pose.to_matrix() mesh.apply_transform(np.linalg.inv(T_map)) mesh.export(f"{out_dir}/{uid}.glb") ``` ### 4.6.2 烘焙 OctoMap(用于 2D 导航) ```python # spatial_memory/bake_octomap.py import open3d as o3d import numpy as np def mesh_to_octomap(global_mesh_path: str, out_bt: str, resolution: float = 0.05): mesh = o3d.io.read_triangle_mesh(global_mesh_path) pc = mesh.sample_points_uniformly(number_of_points=2_000_000) pts = np.asarray(pc.points) # 调外部 octomap 工具(pip install octomap-python 或 octovis) import octomap tree = octomap.OcTree(resolution) for p in pts: tree.updateNode(p, True, lazy_eval=True) tree.updateInnerOccupancy() tree.writeBinary(out_bt.encode()) ``` ### 4.6.3 烘焙 TSDF(用于差异检测) ```python # spatial_memory/bake_tsdf.py import open3d as o3d import numpy as np def mesh_to_tsdf(mesh_path: str, out_vbg: str, voxel: float = 0.02): mesh = o3d.io.read_triangle_mesh(mesh_path) # Open3D 0.18+ 的 VoxelBlockGrid vbg = o3d.t.geometry.VoxelBlockGrid( attr_names=('tsdf', 'weight'), attr_dtypes=(o3d.core.float32, o3d.core.float32), attr_channels=((1,), (1,)), voxel_size=voxel, block_resolution=16, block_count=50000) # 用 mesh 上采样的虚拟"深度图"灌入 # (或直接用 ARKit 留下的真实深度图,质量更好) ... o3d.t.io.write_voxel_block_grid(out_vbg, vbg) ``` > **强烈推荐**用 ARKit 留下的**真实深度帧**而非 mesh 重采样来填 TSDF——保留噪声分布特性,差异检测才公平。 ### 4.6.4 训练 3DGS(可选) ```bash # 用 nerfstudio splatfacto,输入是 ARKit 留的 RGB+pose+depth ns-process-data record3d \ --data data/scans/2026-05-16_3F/arkit/ \ --output-dir data/processed/3F/ ns-train splatfacto \ --data data/processed/3F/ \ --pipeline.model.use-depth-loss True \ --max-num-iterations 15000 ns-export gaussian-splat \ --load-config outputs/3F/splatfacto/config.yml \ --output-dir robot_memory/ltm/dense/ ``` 输出 `ltm/dense/3dgs.ply`。 ### 4.6.5 生成 `prior_mask` 与 `no_update_zone` ```python def build_masks(mem: SpatialMemory, voxel_grid_shape, voxel_size) -> Tuple: prior_mask = np.zeros(voxel_grid_shape, dtype=np.uint8) no_update = np.zeros(voxel_grid_shape, dtype=np.uint8) for n in mem.nodes.values(): if n.bbox_3d is None: continue if not n.attributes.get("mobile", True): mark_obb_in_grid(prior_mask, n.bbox_3d, voxel_size, val=1) if n.label in ("mirror","window","glass_wall") or \ n.attributes.get("reflective", False): mark_obb_in_grid(no_update, n.bbox_3d, voxel_size, val=1) return prior_mask, no_update ``` 镜面识别可以在 iPhone 端就让人工标,或后期跑一遍 ZED 的反射检测——这里允许后补。 --- ## 4.7 计算 CLIP embedding(给 L3 房间 + L4 家具) ```python import open_clip, torch from PIL import Image model, _, preprocess = open_clip.create_model_and_transforms("ViT-B-32") model.eval().cuda() def compute_room_clip(room_uid: str, rgb_dir: str) -> np.ndarray: """房间用 5–10 张代表性 RGB 平均""" imgs = sample_keyframes_for_room(room_uid, rgb_dir, k=8) feats = [] with torch.no_grad(): for img in imgs: x = preprocess(Image.open(img)).unsqueeze(0).cuda() feats.append(model.encode_image(x).cpu().numpy()[0]) return np.mean(feats, axis=0).astype(np.float16) def compute_object_clip(node: SpatialNode, mesh_dir: str, rgb_dir: str) -> np.ndarray: """家具:优先用 mesh 渲染的多视角图;退化为 ARKit 帧裁剪""" # 方法 1:trimesh + pyrender 多视角离线渲染 views = render_mesh_views(f"{mesh_dir}/{node.uid}.glb", num_views=6) feats = [] with torch.no_grad(): for v in views: x = preprocess(v).unsqueeze(0).cuda() feats.append(model.encode_image(x).cpu().numpy()[0]) return np.mean(feats, axis=0).astype(np.float16) ``` > 渲染时背景设为白色 / 透明,避免环境干扰主体语义。 为每个节点存一份 `.npy` 到 `ltm/embeddings/{uid}.clip.npy`,便于在重定位时按需加载。 --- ## 4.8 生成 Anchors(重定位锚点候选集) ```python # spatial_memory/build_anchors.py from .schema import Anchor ANCHOR_LABELS = {"bed","sofa","tv","toilet","bathtub","sink","fridge", "stove","door","wardrobe","fireplace"} def build_anchors(mem: SpatialMemory) -> List[Anchor]: anchors = [] for node in mem.nodes.values(): if node.label not in ANCHOR_LABELS: continue if node.attributes.get("mobile", True): continue # 可移动的不能当锚点 if node.confidence < 0.7: continue # FPFH 几何签名(用 OBB 表面采样点) fpfh = compute_fpfh_signature(node.bbox_3d, node.mesh_uri) anchors.append(Anchor( anchor_uid=node.uid, node_label=node.label, is_mobile=False, clip_embedding=node.clip_embedding, geometric_signature=fpfh, last_validated=time.time())) return anchors ``` 每个房间建议至少有 **2 个不同类的 anchor**(如床+电视),方便 ICP 收敛。 --- ## 4.9 完整 CLI:`prism-ingest-iphone` 把上述阶段串成一个命令: ```python # tools/prism_ingest_iphone.py import click, json, numpy as np from spatial_memory.schema import SpatialMemory from spatial_memory.parser_iphone import parse_roomplan from spatial_memory.align_to_map import find_T_iphone_to_map from spatial_memory.extract_furniture_mesh import split_usdz_per_object from spatial_memory.bake_octomap import mesh_to_octomap from spatial_memory.bake_tsdf import mesh_to_tsdf from spatial_memory.build_anchors import build_anchors from spatial_memory.io_json import save @click.command() @click.option("--scan", required=True, help="data/scans//") @click.option("--out", required=True, help="robot_memory/ltm/") @click.option("--aruco-id", default=42, type=int) def main(scan, out, aruco_id): # 1. 找一帧能看到 ArUco 的图,算 T_iphone→map T = find_T_iphone_to_map_from_dir(f"{scan}/arkit/", aruco_id) if T is None: print("⚠️ no ArUco found, fallback to first-frame origin") T = np.eye(4) # 2. 解析 RoomPlan JSON mem = parse_roomplan(f"{scan}/roomplan.json", T, mesh_dir=f"{out}/meshes/") # 3. 拆 USDZ → 每件家具一份 .glb split_usdz_per_object(f"{scan}/Hotel.usdz", f"{out}/meshes/", mem) # 4. 全局 mesh 也烘焙一份给渲染 global_mesh = bake_global_mesh(f"{scan}/Hotel.usdz", T, f"{out}/dense/global_mesh.glb") # 5. OctoMap & TSDF mesh_to_octomap(f"{out}/dense/global_mesh.glb", f"{out}/dense/octomap.bt", resolution=0.05) mesh_to_tsdf(f"{out}/dense/global_mesh.glb", f"{out}/dense/tsdf.vbg", voxel=0.02) # 6. CLIP 向量 for uid, node in mem.nodes.items(): if node.level in ("L3","L4"): emb = compute_room_clip(uid, f"{scan}/arkit/rgb/") \ if node.level == "L3" \ else compute_object_clip(node, f"{out}/meshes/", f"{scan}/arkit/rgb/") np.save(f"{out}/embeddings/{uid}.clip.npy", emb) node.clip_embedding = emb # 7. Anchors mem.anchors = build_anchors(mem) # 8. dense URI 落到 schema mem.dense.occupancy_grid_uri = "dense/octomap.bt" mem.dense.tsdf_uri = "dense/tsdf.vbg" mem.dense.global_mesh_uri = "dense/global_mesh.glb" mem.dense.global_3dgs_uri = "dense/3dgs.ply" # 若已训练 # 9. masks prior_mask, no_update = build_masks(mem, voxel_grid_shape=(...), voxel_size=0.02) np.save(f"{out}/dense/prior_mask.npy", prior_mask) np.save(f"{out}/dense/no_update_zone.npy", no_update) mem.dense.prior_mask_uri = "dense/prior_mask.npy" mem.dense.no_update_zone_uri = "dense/no_update_zone.npy" # 10. 序列化 + 校验 save(mem, f"{out}/spatial_memory.json") errs = validate(mem) if errs: print("❌ validation errors:") for e in errs: print(" ", e) else: print(f"✅ LTM written to {out} (nodes={len(mem.nodes)}, " f"edges={len(mem.edges)}, anchors={len(mem.anchors)})") if __name__ == "__main__": main() ``` 运行: ```bash python -m tools.prism_ingest_iphone \ --scan data/scans/2026-05-16_3F/ \ --out robot_memory/ltm/ ``` --- ## 4.10 质检清单(每次扫描后必查) | 检查项 | 命令 | 通过标准 | |--------|------|----------| | 节点总数合理 | `jq '.nodes \| length' spatial_memory.json` | 10 房 ≈ 150–500 | | 每房间至少 1 个 anchor | `python -m tools.check_anchors` | ✅ | | 所有 L4 节点都有 `parent_room` | `validate()` | 无 dangling | | 镜面区已标 `no_update_zone` | 可视化 mask | 卫生间镜面 100% 覆盖 | | OctoMap 在客房中央可通行 | `python -m tools.viz_octomap` | 人眼检 | | CLIP 检索"床"返回正确 bed 节点 | `python -m tools.search "床"` | Top-1 命中率 100% | | 重定位测试(同帧重投影) | `python -m tools.test_relocalize` | RMSE < 3 cm | --- ## 4.11 重扫触发条件 iPhone 扫描不是"一次到永远"。下列情况必须重扫: | 触发 | 检测者 | 动作 | |------|--------|------| | 装修 / 大改 | 人工 | 全场景重扫 | | > 30% 大件家具被搬动 | Consolidator 统计 | 自动提示运维 | | Anchor 总数 < 阈值(10 房 < 15 个) | 自检 | 提示补扫 | | LTM 已超 90 天 | 定时任务 | 提示走查(不强制) | 重扫策略:用 `snapshots/` 保留旧版本,新版用 `ltm_version: N+1` 写入,回滚友好。 --- ## 4.12 本章小结 | 阶段 | 输入 | 输出 | |------|------|------| | A1 采集 | 人 + iPhone Pro + ArUco | USDZ + JSON + ARKit raw | | A2 传输 | iPhone | Mac/Linux 上 data/scans/ | | A3 对齐 | ArUco + ARKit pose | `T_iphone→map` | | A4 解析 | RoomPlan JSON | L3+L4 节点与边 | | A5 几何派生 | USDZ + ARKit 深度 | OctoMap + TSDF + 3DGS + 每件 mesh + masks | | A6 CLIP | RGB + mesh 渲染 | 每节点的 (512,) embedding | | A7 Anchors | 节点子集 | `anchors.json` | | A8 落盘 | 所有上述 | `robot_memory/ltm/` | 完成本管线后,**LTM 已就绪**,等待 ZED 2i 上机器人后做 [Chapter 05](05_pipeline_B_relocalization.md) 的握手重定位。 --- **章节版本**:v1.0 **估计阅读时间**:20 分钟 **关键收获**:可立即跑通的 `prism-ingest-iphone` 完整管线