- 处理: 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
24 KiB
title, date, draft, tags, categories
| title | date | draft | tags | categories | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| Chapter 04 — 管线 A:iPhone 离线建图 | 2026-05-20 | false |
|
|
Chapter 04 — 管线 A:iPhone 离线建图
本章目标:把 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 阶段 A1:iPhone 端采集
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. 房间扫描 | 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:数据传输
flowchart TB
subgraph IOS["iPhone Files App<br/><i>On My iPhone/PRISMScanner/</i>"]
F1["Hotel-3F.usdz<br/><i>(4–20 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,短边指 +y,z 向上。
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: 某帧 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 结构(核心字段)
{
"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_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
# 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_mask 与 no_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:
"""房间用 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(重定位锚点候选集)
# 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
把上述阶段串成一个命令:
# 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 房 ≈ 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 的握手重定位。
章节版本:v1.0
估计阅读时间:20 分钟
关键收获:可立即跑通的 prism-ingest-iphone 完整管线