# Chapter 02 — 四层空间记忆架构 > 本章目标:把机器人大脑里的"空间记忆"拆成 **L1–L4 四层**,逐层说清楚:**存什么 / 怎么存 / 谁写 / 谁读 / 何时过期。** --- ## 2.1 为什么是四层?而不是 1 层、3 层、7 层? ### 不能是 1 层的原因 单一表示(如纯点云)既不能高频更新(点云重写太慢),也不能直接被 LLM 查询(没有语义)。 ### 不能是 3 层的原因 若合并 L3 拓扑与 L4 语义为一层,**路径规划**(需要拓扑边权)与**问答**(需要属性查询)会互相干扰。 ### 不能更多(如 7 层)的原因 分层越多,**层间同步成本越高**。四层来自三个现实约束的交集: ```mermaid flowchart TB subgraph C["认知科学"] direction LR C1["Working"] <--> C2["Short-term"] C2 <--> C3["Long-term"] end subgraph S["SLAM 工程"] direction LR S1["Metric"] <--> S2["Topological"] S2 <--> S3["Semantic"] end subgraph R["机器人控制"] direction LR R1["Reactive (Hz 级)"] <--> R2["Deliberative (秒级)"] end subgraph L["PRISM 四层(三家传统的最小公倍数)"] direction LR L1["L1
感知缓冲"] --- L2["L2
度量"] --- L3["L3
拓扑"] --- L4["L4
语义"] end C -. 分化 .-> S S -. 加感知缓冲 .-> R R -. 取并集 .-> L style L fill:#fff7d6,stroke:#c97a00,stroke-width:2px ``` 四层是这三套传统在工程上的**最小公倍数**。 --- ## 2.2 四层全景图 ```mermaid flowchart LR subgraph PRISM["PRISM Spatial Memory(左:高频・易变・低抽象 → 右:低频・稳定・高抽象)"] direction LR L1["L1 感知缓冲
Perceptual
──────────
位姿环形缓冲
深度环形缓冲
──────────
ZED 2i 主写
更新:30 Hz
寿命:ms 级"] L2["L2 度量
Metric
──────────
占据栅格
TSDF
3DGS
──────────
iPhone+ZED 共写
更新:5 Hz
寿命:秒级"] L3["L3 拓扑
Topological
──────────
房间节点 + 边
锚点列表
──────────
iPhone 主写
更新:低频
寿命:分钟级"] L4["L4 语义
Semantic
──────────
场景图 Neo4j
bbox 属性
──────────
iPhone+ZED 共写
更新:极低频
寿命:小时级"] L1 --> L2 --> L3 --> L4 end style L1 fill:#fde2e2,stroke:#a33 style L2 fill:#fff1c1,stroke:#a87a00 style L3 fill:#d4f0d4,stroke:#2e7d32 style L4 fill:#d8e4ff,stroke:#1565c0 ``` 记住三条直觉: 1. **越往右越"抽象"**:L1 是像素和位姿,L4 是 "the lamp is on the nightstand" 2. **越往右越"慢"**:L1 ms 级覆盖,L4 小时级更新 3. **越往右越"小"**:L1 几 GB/小时,L4 几 MB/全场景 --- ## 2.3 L1 — 感知缓冲 (Perceptual Buffer) ### 2.3.1 定位 "机器人最近几秒看到的所有东西"——纯粹的**工作记忆**,类似人的视觉残留。 ### 2.3.2 存什么 ```python @dataclass class PerceptualFrame: timestamp: float pose: Pose # T_robot→map (ZED VIO 输出) rgb_left: np.ndarray # (H,W,3) 可选保存 depth: np.ndarray # (H,W) imu_packet: List[IMUSample] # 自上一帧以来的 IMU keypoints: Optional[np.ndarray] # ORB/SuperPoint 关键点 class L1Buffer: capacity_seconds: float = 10.0 # 环形缓冲容量 keyframe_interval: float = 0.5 # 关键帧采样间隔 ring: Deque[PerceptualFrame] # 满则覆盖 keyframes: Deque[PerceptualFrame] # 关键帧池(保留更久) ``` ### 2.3.3 写者 / 读者 | 角色 | 频率 | 操作 | |------|------|------| | **写**:ZED 2i 驱动 | 30 Hz | `ring.append(frame)` | | **读**:避障 / 局部规划 | 10 Hz | 取最近 1 s 的 frames | | **读**:L2 融合器 | 5 Hz | 取一个关键帧融入 TSDF | | **读**:回环检测 | 1 Hz | 与关键帧池做相似度匹配 | ### 2.3.4 过期规则 - 超过 `capacity_seconds` 的非关键帧 → 直接丢弃 - 关键帧:若已成功融入 L2 → 5 分钟后可丢弃 - 全部仅在内存,不落盘(除非 debug) ### 2.3.5 失效与降级 - VIO 跟丢 → L1 暂停接收 → 触发重定位(见 [`05_pipeline_B_relocalization.md`](05_pipeline_B_relocalization.md)) - IMU 异常 → 仅用视觉位姿,标记 `confidence=low` --- ## 2.4 L2 — 度量记忆 (Metric Memory) > ⚠️ **v1.5 更新**:本节后续提及"L2 voxel 直接生成 L3/L4 节点内容"的写法已被 [§ 2.7b L2-L3 数据流:路由 vs 内容(v1.5 新原则)](#27b-l2-l3-数据流路由-vs-内容v15-新原则) 取代。L2 几何在 v1.5 起仅用作**路由信号**,不再承担"决定节点内容"的职责。旧描述保留以备历史追溯。 ### 2.4.1 定位 "3D 几何长什么样"——机器人能在上面**规划路径、避障、渲染**的稠密表示。 ### 2.4.2 存什么(多重表示) ```python @dataclass class L2Memory: # 用途 1:导航——2D/2.5D 占据栅格 occupancy_grid: OctoMap # 5 cm 分辨率,0=空 1=占 -1=未知 # 用途 2:精细几何——TSDF / Mesh tsdf: VoxelBlockGrid # 2 cm 分辨率 mesh_uri: str # 离线烘焙的 .glb # 用途 3:渲染 / 视觉相似度——3DGS gaussians_uri: Optional[str] # .ply,每房间一份 # 用途 4:先验掩膜 prior_mask: np.ndarray # 0=ZED 可写 1=iPhone 静态保护 no_update_zone: np.ndarray # 1=镜面/玻璃,禁止写入 ``` 为什么要 4 套表示? | 表示 | 谁用 | 为什么不能替代 | |------|------|----------------| | OctoMap | 路径规划器 | 路径规划只关心"能不能走",cm 级足够 | | TSDF | 差异检测 / 抓取 | 需要带符号距离才能算 SDF 差 | | Mesh | 可视化 / Unity 仿真 | 渲染流水线友好 | | 3DGS | 视觉重定位 / 新视角生成 | 比 mesh 真实得多 | 它们**共享同一份原始点云**,只是不同的"派生视图"。 ### 2.4.3 写者 / 读者 | 区域 | 主写者 | 来源 | 频率 | |------|--------|------|------| | **静态结构**(墙、门、固定家具) | iPhone | RoomPlan mesh | 1 次/场景 | | **可变区域**(家具间空隙、地面) | ZED 2i | TSDF 增量 | 5 Hz 局部 | | **新发现区**(iPhone 没扫到) | ZED 2i | TSDF 新建 voxel | 5 Hz | | **镜面/玻璃** | 都不写 | — | — | ### 2.4.4 关键规则:先验保护 ```python def fuse_zed_to_l2(zed_tsdf_local, l2: L2Memory): for voxel in zed_tsdf_local: if l2.no_update_zone[voxel.idx]: continue # 镜面区,跳过 if l2.prior_mask[voxel.idx]: # 先验区:只允许微调(低权重) l2.tsdf.update(voxel, weight=0.1) else: # 自由区:正常融合 l2.tsdf.update(voxel, weight=1.0) ``` → iPhone 提供的墙不会被一次 ZED 噪声毁掉,但小幅度(< 5 cm)的修正可累积起效。 ### 2.4.5 过期规则 - iPhone 写入的体素 → 长期保留,但有 `last_seen` 字段 - ZED 写入的体素:30 天内未被再次确认 → 衰减 confidence - 若某区域被打上 `delta`(家具搬走)→ 在 Consolidator 跑后才物理移除 --- ## 2.5 L3 — 拓扑记忆 (Topological Memory) > ⚠️ **v1.5 更新**:本节中"由 L2 几何(TSDF/OctoMap)反推生成 L3 节点内容"的旧写法已被 [§ 2.7b L2-L3 数据流:路由 vs 内容(v1.5 新原则)](#27b-l2-l3-数据流路由-vs-内容v15-新原则) 取代。L3 节点的 `clip_embedding` / `polygon` / 锚点描述等**内容**改为从 L1 高质量 keyframe 直接获取;L2 仅决定"写哪个节点"。旧描述保留以备历史追溯。 ### 2.5.1 定位 "房间和走廊怎么连"——机器人**长距离导航**的骨架。 ### 2.5.2 存什么 ```python @dataclass class L3Node: uid: str # "room_301" label: str # "Bedroom" / "Hallway" / "Lobby" center: np.ndarray # (3,) 房间几何中心 polygon: np.ndarray # (N,2) 房间地面多边形 anchors: List[str] # 引用 L4 中的家具 uid(如 'bed_301') clip_embedding: np.ndarray # (512,) 房间整体视觉指纹 @dataclass class L3Edge: src: str # "room_301" dst: str # "hallway_3F" via: str # 连接介质:'door_301a' / 'open_passage' cost: float # 通行成本(距离 + 难度) bidirectional: bool = True class L3Memory: nodes: Dict[str, L3Node] edges: List[L3Edge] anchor_index: Dict[str, str] # furniture_uid -> room_uid 反向索引 ``` ### 2.5.3 写者 / 读者 | 角色 | 何时 | 操作 | |------|------|------| | **写**:iPhone 解析器 | 离线一次 | 按 RoomPlan 的 room 切分自动生成节点;门窗作为边 | | **写**:ZED 2i + 巡逻 | 机器人实际穿过门时 | 确认/新增边,更新 `cost` | | **读**:高层规划器 | 任务下发时 | 在 L3 图上跑 A* / Dijkstra 得"房间序列" | | **读**:重定位器 | 上电 / 跟丢时 | 用 `clip_embedding` 做粗匹配 | ### 2.5.4 过期规则 - 房间节点:除非装修,否则永久保留 - 边:连续 N 次(默认 3 次)巡逻都走不通 → 标记 `deprecated`,路径规划器跳过 - 锚点:被 ZED 检测到原家具消失 → 从 `anchors` 中移除,但节点不删 ### 2.5.5 与 L4 的区别(容易混淆!) - **L3 是"地理"**:节点是**地方**(房间、走廊),边是**通行关系** - **L4 是"物品"**:节点是**东西**(床、灯、遥控器),边是**支撑/包含/邻接关系** - L3 节点 `room_301` 通过 `anchors` 字段指向 L4 节点 `bed_301`, `tv_301`... --- ## 2.6 L4 — 语义记忆 (Semantic Memory) ### 2.6.1 定位 "哪个东西在哪儿、长啥样、跟谁挨着"——机器人**理解任务**和**与人对话**的底座。 ### 2.6.2 存什么 ```python @dataclass class L4Node: uid: str # 'bed_301', 'lamp_301_a' label: str # 'bed' / 'lamp' / 'remote_control' category: str # 'furniture' / 'appliance' / 'small_item' pose: Pose bbox_3d: np.ndarray # (8,3) OBB mesh_uri: Optional[str] clip_embedding: np.ndarray # (512,) attributes: Dict # {color, material, state(on/off/open/closed), # mobile: bool, fragile: bool, ...} parent_room: str # L3 房间 uid source: Literal['iphone','zed2i','vlm','fused'] first_seen: float last_seen: float observation_count: int confidence: float @dataclass class L4Edge: src_uid: str dst_uid: str relation: Literal['on','under','in','next_to', 'inside_drawer','plugged_into',...] confidence: float class L4Memory: nodes: Dict[str, L4Node] edges: List[L4Edge] spatial_index: Optional[KDTree] # 加速"附近的东西"查询 semantic_index: Optional[Faiss] # CLIP 向量库,加速文本查物体 ``` ### 2.6.3 写者 / 读者 | 角色 | 频率 | 操作 | |------|------|------| | **写**:iPhone 解析器 | 离线一次 | 16 类家具直接落库(高 confidence) | | **写**:ZED + VLM | 2 Hz | 检出物品,与现有节点匹配或新建 | | **写**:Consolidator | 充电时 | 确认 `delta` 并固化 | | **读**:LLM Agent | 按需 | `find("遥控器在哪")` → CLIP 检索 + 关系遍历 | | **读**:抓取规划器 | 任务时 | 取目标 `bbox_3d` + `attributes.fragile` | | **读**:渲染器 | 可视化 | 取所有 `mesh_uri` | ### 2.6.4 三类节点的不同生命周期 | 类型 | 例子 | mobile | 谁写 | 多久过期 | |------|------|--------|------|----------| | **固定结构** | 墙、门、内嵌衣柜 | false | iPhone | 永不 | | **大件家具** | 床、沙发、书桌 | half | iPhone 主,ZED 校准 | 季度级 | | **小物品** | 遥控器、水杯、毛巾 | true | ZED + VLM 主 | 小时级 | `mobile` 标签直接影响重定位是否能用它当 anchor(见 [`05_pipeline_B_relocalization.md`](05_pipeline_B_relocalization.md))。 ### 2.6.5 关系(Edge)的两种来源 ```python # 1. 几何派生:通过 bbox 相对位置自动得出 def derive_edge_from_geometry(a: L4Node, b: L4Node) -> Optional[L4Edge]: if a.bbox_3d.contains(b.bbox_3d.center): return L4Edge(b.uid, a.uid, 'in', confidence=0.9) if abs(b.bbox_min_z - a.bbox_max_z) < 0.05: return L4Edge(b.uid, a.uid, 'on', confidence=0.85) if a.distance_to(b) < 0.5: return L4Edge(a.uid, b.uid, 'next_to', confidence=0.7) return None # 2. VLM 派生:让 GPT-4V / Qwen-VL 看图给关系 def derive_edge_from_vlm(rgb_image, detections) -> List[L4Edge]: prompt = f"Given these detected objects {detections}, " \ f"list spatial relations as (a, relation, b)." return vlm.parse_relations(rgb_image, prompt) ``` 两路边都允许存在,按 `confidence` 加权融合。 --- ## 2.7 层间交互规则 ### 2.7.1 写入顺序(数据如何流入大脑) ```mermaid flowchart TB ZED["ZED 2i
原始数据"] VLM["VLM
(在线感知)"] L1["L1 感知缓冲"] L2["L2 度量"] DELTA[("delta/
差异目录")] L4["L4 语义"] L3["L3 拓扑"] CON["Consolidator
(充电时跑)"] IPHONE["iPhone
(离线一次)"] LTM[("L2 + L3 + L4
永久保留")] ZED -- "30 Hz 写入" --> L1 ZED -- "5 Hz 关键帧" --> L2 ZED -- "2 Hz" --> VLM L2 -- "差异检测" --> DELTA VLM -- "关联" --> L4 DELTA -- "充电时确认" --> CON CON --> L4 L4 -- "更新 anchors" --> L3 IPHONE -- "离线一次" --> LTM style L1 fill:#fde2e2,stroke:#a33 style L2 fill:#fff1c1,stroke:#a87a00 style L3 fill:#d4f0d4,stroke:#2e7d32 style L4 fill:#d8e4ff,stroke:#1565c0 style CON fill:#ffe9b3,stroke:#c97a00 style DELTA fill:#f5e1ff,stroke:#7b1fa2 ``` ### 2.7.2 查询顺序(Agent 怎么读大脑) 最常见的查询是 **"找东西 + 怎么去"**,标准流程: ```python def query_find_and_navigate(text: str, memory): # 1. L4 语义搜索:文本 → 物品 uid obj_uid = memory.l4.semantic_search(text) # CLIP obj = memory.l4.nodes[obj_uid] # 2. L3 拓扑规划:当前房间 → 物品所在房间 cur_room = memory.l3.locate(memory.l1.current_pose) path_rooms = memory.l3.astar(cur_room, obj.parent_room) # 3. L2 度量规划:物品所在房间内的精确路径 metric_path = memory.l2.plan_path( start=memory.l1.current_pose, goal=obj.pose, room_sequence=path_rooms) # 4. L1 实时跟随 + 避障 follow_path(metric_path) return obj ``` → **从右向左下钻**:抽象 → 具体,是 PRISM 查询的标准范式。 ### 2.7.3 写入冲突的仲裁 当 iPhone 和 ZED 对同一区域有不同观测: | 情况 | 仲裁规则 | |------|----------| | iPhone 说有墙,ZED 说没墙 | 单次→忽略 ZED;连续 N 帧→写 delta;Consolidator 确认才删墙 | | iPhone 没标家具,ZED 检到家具 | 立即写 L4 新节点,但 `confidence` 起始 0.3,多次确认才上升 | | iPhone 标了 lamp,ZED 检到 lamp 但位置偏 30 cm | ZED 微调 `pose`,confidence 加权平均 | | iPhone 标了 chair(mobile=true),ZED 没看到 | 不立即删,标记 `state='moved'`,consolidate 决定 | 总原则:**iPhone 写入的内容"假设正确直到证据充分相反"**。 --- ## 2.7b L2-L3 数据流:路由 vs 内容(v1.5 新原则) > v1.5 起 PRISM 借鉴 Lyra 2.0 的核心思想——**"几何只做路由,不做合成"**——重新厘清 L2 与 L3 之间的职责边界。详细动机与 Lyra 2.0 的对照参见 [`18_lyra_inspirations.md`](18_lyra_inspirations.md)(尤其是 §18.2)。在此之前,PRISM 的 L2 既负责几何稠密表示,又承担"把 voxel 内容写入 L3 节点"的合成职责,导致 L3 节点的视觉指纹(CLIP 嵌入)、bounding box、文字描述等都受 L2 voxel 量化精度限制;新原则把这两件事彻底解耦。 ### 2.7b.1 路由 vs 内容:两条数据流分工 ```mermaid flowchart LR L1KF["L1 keyframe
full-res RGB-D
+ CLIP embedding
(高保真原始观测)"] L2GEO["L2 几何
TSDF / OctoMap
(低精度,可量化)"] L3NODE["L3 节点写入
clip_embedding
polygon / anchors
(高保真内容)"] L1KF -- "内容来源
(高保真)" --> L3NODE L2GEO -. "仅作路由信号
(决定写哪个节点)" .-> L3NODE style L1KF fill:#fde2e2,stroke:#a33 style L2GEO fill:#fff1c1,stroke:#a87a00 style L3NODE fill:#d4f0d4,stroke:#2e7d32 ``` - **L2 几何 = 路由信号**:仅用来判断"当前观测的 3D 位置应该归属到 L3 图里的哪个节点"。即使 L2 的 voxel 是 2 cm 量化、含噪声、甚至局部缺失,只要它能**指向正确的 L3 节点 uid**就够了。 - **L1 keyframe = 内容源**:节点的 `clip_embedding`、视觉证据、bounding box、属性描述等**实际内容**必须来自 L1 缓存里那一张未经量化的高分辨率 RGB-D keyframe(或它的特征),而非从 L2 voxel 反投影回来。 一句话总结:**几何精度只需要够"指向哪个节点",不需要"决定节点内容"**。 ### 2.7b.2 路由式写入伪代码 ```python def write_to_l3(observation, l2_geometry, l3_graph): # ① L2 只参与"路由":根据观测的 3D 位置定位 L3 节点 uid target_node = route_via_l2(observation.position, l2_geometry) # ② 内容来源是 L1 高保真 keyframe,而非 L2 voxel keyframe_evidence = get_best_keyframe(observation, l3_graph[target_node]) # ③ 用 keyframe 的原始 CLIP / bbox / patch 更新节点内容 l3_graph[target_node].update(keyframe_evidence) ``` 四行核心逻辑里,L2 只出现在第一步(`route_via_l2`)且只读;真正塑造 L3 节点内容的是第二步从 L1 取回的 `keyframe_evidence`。这一拆分让 L2 即使在 OctoMap 5 cm + TSDF 噪声的精度下仍然足以胜任,而 L3 的语义保真度由 L1 决定。 ### 2.7b.3 对原有章节的影响一览 | 原章节 | 旧写法 | v1.5 新原则下的对应 | |--------|--------|----------------------| | §2.4 L2 度量 | L2 既存几何又"派生"L3 内容 | L2 仅做几何 + 路由;不再向 L3 派生内容 | | §2.5 L3 拓扑 | `clip_embedding` 由 voxel 颜色聚合 | 由 L1 keyframe 直接编码 | | §2.7.1 写入顺序图 | `L2 --差异检测--> DELTA` 仍然成立 | 但 L3 节点**内容更新**的箭头改为来自 L1,L2 只贡献节点 uid 路由 | | `06_pipeline_C` §6.6 | TSDF 体素直接重投影生成 patch | 见 [§ 6.6b keyframe-based 内容更新](06_pipeline_C_online_perception.md#66b-v15-新写法keyframe-based-内容更新代替-l2-几何反推) | --- ## 2.8 整体存储预算 以一个 10 房间酒店楼层为例: | 层 | 数据 | 大小 | |----|------|------| | L1 | 10 s 环形缓冲(深度+RGB) | ~ 2 GB 内存 | | L2 | 10 房间 × (OctoMap 50 MB + TSDF 100 MB + 3DGS 200 MB) | ~ 3.5 GB 磁盘 | | L3 | 10 节点 + 边 + CLIP 向量 | ~ 100 KB | | L4 | ~ 500 nodes (10 房 × 50 件) + 关系 | ~ 50 MB | | **总** | | **~ 4 GB 磁盘 + 2 GB 内存** | → 完全可以在 Jetson Orin (32–64 GB) 上跑。 --- ## 2.9 本章小结 | 层 | 一句话 | 主写者 | 平均寿命 | |----|--------|--------|----------| | **L1** 感知缓冲 | 最近几秒的位姿+深度 | ZED 2i | 10 秒 | | **L2** 度量 | 3D 几何(导航/渲染用) | iPhone 静态 + ZED 增量 | 月级 | | **L3** 拓扑 | 房间-走廊连通图 | iPhone | 永久 | | **L4** 语义 | 物品-关系场景图 | iPhone 大件 + ZED+VLM 小件 | 小时~季度 | 四层不是平行的,而是**沿"频率×抽象"轴展开的连续谱**。 读完本章你应能: - ✅ 解释每一层"是什么" - ✅ 解释每一层"由谁主写、谁主读" - ✅ 理解层间写入冲突的仲裁规则 - ✅ 估算一个 10 房间场景的存储预算 下一章 [`03_data_schema.md`](03_data_schema.md) 给出可直接复制运行的 Python `dataclass` schema + 序列化格式。 --- **章节版本**:v1.0 **估计阅读时间**:18 分钟 **关键收获**:四层结构的"存什么/谁写谁读/何时过期"完整规则