chore: initial commit — import worldmodel workspace (plans/, research/)
This commit is contained in:
@@ -0,0 +1,663 @@
|
||||
# Chapter 04 — 管线 A:iPhone 离线建图
|
||||
|
||||
> 本章目标:把 iPhone Pro 扫描得到的 **USDZ / RoomPlan JSON / 原始 ARKit 数据**,转换为 PRISM 的 **`SpatialMemory` 长期记忆 LTM**,包括 L2 度量 + L3 拓扑 + L4 语义。
|
||||
|
||||
---
|
||||
|
||||
## 4.1 管线总览
|
||||
|
||||
```mermaid
|
||||
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 骨架
|
||||
|
||||
```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<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 跑):
|
||||
|
||||
```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/<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()
|
||||
```
|
||||
|
||||
运行:
|
||||
|
||||
```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` 完整管线
|
||||
Reference in New Issue
Block a user