Files
worldmodel/plans/PRISM/04_pipeline_A_iphone_offline.md
T
gaojie dbcbdbb59e chore: 为所有 md 文件添加 Hugo front matter
- 处理: 74 个 .md 文件
- 跳过: 0 个(无已存在的 front matter)
- 异常: 3 个(H1 缺失,用文件名兜底)
  - plans/PRISM/.research/readmes/3d-llm.md
  - plans/PRISM/.research/readmes/openmask3d.md
  - plans/PRISM/.research/readmes/openscene.md
2026-05-20 22:42:22 +08:00

24 KiB
Raw Blame History

title, date, draft, tags, categories
title date draft tags categories
Chapter 04 — 管线 AiPhone 离线建图 2026-05-20 false
PRISM
世界模型
空间记忆
点云
Gaussian Splatting
iOS
worldmodel

Chapter 04 — 管线 AiPhone 离线建图

本章目标:把 iPhone Pro 扫描得到的 USDZ / RoomPlan JSON / 原始 ARKit 数据,转换为 PRISM 的 SpatialMemory 长期记忆 LTM,包括 L2 度量 + L3 拓扑 + L4 语义。


4.1 管线总览

flowchart LR
    A["📱 iPhone Pro<br/>RoomPlan + ARKit RAW<br/><b>1. 采集</b>"]
    B["💻 Mac / Linux<br/>上传 / 下载<br/><b>2. 传输</b>"]
    C["🐍 Parser & Glue<br/>(Python)<br/><b>3. 解析 + 几何处理</b>"]
    D[("🧠 PRISM LTM<br/>写入<br/><b>4. 落盘</b>")]
    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 阶段 A1iPhone 端采集

4.2.1 推荐的 Swift App 骨架

// 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. 房间扫描 1015 min 沿墙慢走,距墙 11.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:数据传输

flowchart TB
    subgraph IOS["iPhone Files App<br/><i>On My iPhone/PRISMScanner/</i>"]
        F1["Hotel-3F.usdz<br/><i>(420 MB)</i>"]
        F2["roomplan.json<br/><i>(~50 KB)</i>"]
        subgraph ARK["arkit/"]
            A1["frames.h5<br/><i>深度图序列, 200 MB ~ 2 GB</i>"]
            A2["rgb/*.jpg"]
            A3["poses.tum"]
        end
    end
    TRANS["iCloud / scp over WiFi (Mac) / WebDAV"]
    REPO[("Project repo:<br/>data/scans/2026-05-16_3F/")]
    IOS --> TRANS --> REPO
    style IOS fill:#e3f2fd,stroke:#1565c0
    style REPO fill:#fff7d6,stroke:#c97a00

传输脚本(在 Mac/Linux 跑):

# 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,短边指 +yz 向上

iPhone 扫描时只要拍到这个标识就能算出 T_iphone→map

# 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: 某帧 RGBarkit_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 结构(核心字段)

{
  "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 解析器实现

# 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_matrixmatrix_to_quatobb_from_transform_and_dimsfind_room_for_point 是标准几何工具,略。


4.6 阶段 A5:几何派生(mesh → OctoMap/TSDF/3DGS + 切件 mesh

4.6.1 从 USDZ 拆出每件家具的 mesh

# 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 导航)

# 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(用于差异检测)

# 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(可选)

# 用 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_maskno_update_zone

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 家具)

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:
    """房间用 510 张代表性 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 帧裁剪"""
    # 方法 1trimesh + 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)

渲染时背景设为白色 / 透明,避免环境干扰主体语义。

为每个节点存一份 .npyltm/embeddings/{uid}.clip.npy,便于在重定位时按需加载。


4.8 生成 Anchors(重定位锚点候选集)

# 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 完整 CLIprism-ingest-iphone

把上述阶段串成一个命令:

# 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/<NAME>/")
@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()

运行:

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 房 ≈ 150500
每房间至少 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 的握手重定位。


章节版本v1.0 估计阅读时间20 分钟 关键收获:可立即跑通的 prism-ingest-iphone 完整管线