655 lines
25 KiB
Markdown
655 lines
25 KiB
Markdown
# Chapter 03 — 统一数据模型 (SpatialMemory Schema)
|
||
|
||
> 本章目标:给出 **可直接复制运行** 的 Python schema、磁盘目录布局、JSON/USD 序列化规范,作为 PRISM 全系统的"数据宪法"。
|
||
|
||
---
|
||
|
||
## 3.1 设计原则
|
||
|
||
| 原则 | 含义 |
|
||
|------|------|
|
||
| **单一真理源** | 所有层共享 `SpatialMemory` 一个根对象;不允许"iPhone 数据库 + ZED 数据库"并立 |
|
||
| **自描述** | 每个节点带 `source`/`level`/`confidence`/`timestamps`,断网恢复后能自解释 |
|
||
| **可演化** | Schema 加字段不破坏旧数据;用 `schema_version` 标记 |
|
||
| **可序列化** | JSON 用于人读 + Git diff;HDF5/PLY/GLB 用于大数据;USD 用于与 Omniverse/Isaac 对接 |
|
||
| **可索引** | 关键查询("附近的""相似的""最近见到的")都有 O(log n) 索引支持 |
|
||
|
||
---
|
||
|
||
## 3.2 完整 Python Schema
|
||
|
||
```python
|
||
# spatial_memory/schema.py
|
||
"""PRISM Spatial Memory — canonical schema v1.0"""
|
||
from __future__ import annotations
|
||
from dataclasses import dataclass, field, asdict
|
||
from typing import Dict, List, Optional, Literal, Any, Tuple
|
||
from enum import Enum
|
||
import numpy as np
|
||
import time
|
||
import uuid
|
||
|
||
SCHEMA_VERSION = "1.0.0"
|
||
|
||
# ────────────────────── 基础类型 ──────────────────────
|
||
|
||
@dataclass
|
||
class Pose:
|
||
"""SE(3) 位姿;统一在 `map` 帧;右手系,z 向上"""
|
||
position: np.ndarray # (3,) float32 [x,y,z] 单位 m
|
||
quaternion: np.ndarray # (4,) float32 [w,x,y,z] 单位四元数
|
||
frame_id: str = "map"
|
||
|
||
def to_matrix(self) -> np.ndarray:
|
||
# 返回 4×4 齐次矩阵
|
||
from scipy.spatial.transform import Rotation as R
|
||
T = np.eye(4)
|
||
T[:3, :3] = R.from_quat(
|
||
[self.quaternion[1], self.quaternion[2],
|
||
self.quaternion[3], self.quaternion[0]]).as_matrix()
|
||
T[:3, 3] = self.position
|
||
return T
|
||
|
||
|
||
class MemoryLevel(str, Enum):
|
||
L1 = "L1" # 感知缓冲
|
||
L2 = "L2" # 度量
|
||
L3 = "L3" # 拓扑
|
||
L4 = "L4" # 语义
|
||
|
||
|
||
class Source(str, Enum):
|
||
IPHONE = "iphone"
|
||
ZED2I = "zed2i"
|
||
VLM = "vlm"
|
||
FUSED = "fused"
|
||
HUMAN = "human" # 人工标注/纠正
|
||
|
||
|
||
# ────────────────────── 节点 ──────────────────────
|
||
|
||
@dataclass
|
||
class SpatialNode:
|
||
"""统一节点:可表示房间(L3)/家具(L4)/物品(L4)/路点(L2,可选)
|
||
|
||
> ⚠️ v1.5 更新:本类新增 `keyframe_evidence: List[KeyframeEvidence]` 字段,
|
||
> 用于保留 per-frame 独立"翻案证据"。完整动机、字段定义、存储预算与
|
||
> retention 策略详见 § 3.2.1(紧随本 Schema 块之后的新增小节)。
|
||
> 旧字段语义不变;本注解仅作为升级提示,原 v1.0 字段全部保留。
|
||
"""
|
||
# ── 标识 ──
|
||
uid: str
|
||
label: str
|
||
level: MemoryLevel
|
||
source: Source
|
||
confidence: float = 1.0 # 0~1
|
||
|
||
# ── 几何 ──
|
||
pose: Optional[Pose] = None
|
||
bbox_3d: Optional[np.ndarray] = None # (8,3) OBB 顶点
|
||
polygon_2d: Optional[np.ndarray] = None # (N,2) 仅 L3 房间地面用
|
||
mesh_uri: Optional[str] = None # 相对 robot_memory/ 的路径
|
||
gaussians_uri: Optional[str] = None
|
||
|
||
# ── 语义 ──
|
||
category: Optional[str] = None # 'wall','furniture','small_item','room'
|
||
attributes: Dict[str, Any] = field(default_factory=dict)
|
||
# attributes 常用键:
|
||
# color, material, state(on/off/open/closed),
|
||
# mobile: bool, fragile: bool,
|
||
# is_anchor: bool, no_update_zone: bool
|
||
clip_embedding: Optional[np.ndarray] = None # (512,) float16
|
||
|
||
# ── 时序 ──
|
||
first_seen: float = field(default_factory=time.time)
|
||
last_seen: float = field(default_factory=time.time)
|
||
observation_count: int = 1
|
||
|
||
# ── 关联 ──
|
||
parent_uid: Optional[str] = None # L4 物品挂在哪件家具上
|
||
parent_room: Optional[str] = None # 反向:所属 L3 房间
|
||
children: List[str] = field(default_factory=list)
|
||
|
||
@staticmethod
|
||
def new_uid(prefix: str = "n") -> str:
|
||
return f"{prefix}_{uuid.uuid4().hex[:8]}"
|
||
|
||
|
||
# ────────────────────── 边 ──────────────────────
|
||
|
||
RelationT = Literal[
|
||
"contains", "in", # 容纳
|
||
"on", "under", # 支撑
|
||
"next_to", "front_of", "behind", # 邻接
|
||
"connects_to", "reachable_from", # L3 通行
|
||
"plugged_into", "inside_drawer", # 特殊
|
||
]
|
||
|
||
@dataclass
|
||
class SpatialEdge:
|
||
src_uid: str
|
||
dst_uid: str
|
||
relation: RelationT
|
||
weight: float = 1.0 # 距离/通行成本/置信度
|
||
source: Source = Source.FUSED
|
||
timestamp: float = field(default_factory=time.time)
|
||
|
||
|
||
# ────────────────────── 稠密层(不入节点)──────────────────────
|
||
|
||
@dataclass
|
||
class DenseLayerRefs:
|
||
"""L2 稠密表示:用文件 URI 引用,不放内存"""
|
||
occupancy_grid_uri: Optional[str] = None # .bt (OctoMap)
|
||
tsdf_uri: Optional[str] = None # .vbg / .npz
|
||
global_mesh_uri: Optional[str] = None # .glb
|
||
global_3dgs_uri: Optional[str] = None # .ply (gsplat 格式)
|
||
prior_mask_uri: Optional[str] = None # .npy (1=iPhone 保护)
|
||
no_update_zone_uri: Optional[str] = None # .npy (1=镜面)
|
||
|
||
|
||
# ────────────────────── 锚点 ──────────────────────
|
||
|
||
@dataclass
|
||
class Anchor:
|
||
"""用于 ZED 上线时与先验地图配准的锚点"""
|
||
anchor_uid: str # 引用 SpatialNode.uid
|
||
node_label: str # 'bed','tv',...
|
||
is_mobile: bool # mobile=True 则不作 anchor
|
||
clip_embedding: np.ndarray
|
||
geometric_signature: Dict # FPFH 直方图等几何特征
|
||
last_validated: float
|
||
|
||
|
||
# ────────────────────── Delta(差异记忆)──────────────────────
|
||
|
||
@dataclass
|
||
class DeltaEvent:
|
||
"""ZED 在线发现的、与 LTM 不一致的事件"""
|
||
event_id: str
|
||
event_type: Literal["object_moved", "object_removed",
|
||
"object_added", "geometry_changed"]
|
||
target_uid: Optional[str] # 涉及的 LTM 节点(若有)
|
||
new_pose: Optional[Pose] = None
|
||
new_bbox: Optional[np.ndarray] = None
|
||
evidence: List[str] = field(default_factory=list) # 关键帧 ID 列表
|
||
observation_count: int = 1
|
||
first_observed: float = field(default_factory=time.time)
|
||
last_observed: float = field(default_factory=time.time)
|
||
status: Literal["pending","confirmed","rejected","applied"] = "pending"
|
||
|
||
|
||
# ────────────────────── 根对象 ──────────────────────
|
||
|
||
@dataclass
|
||
class SpatialMemory:
|
||
schema_version: str = SCHEMA_VERSION
|
||
world_frame: str = "map"
|
||
origin_description: str = "scan start point of iPhone session 1"
|
||
gravity: np.ndarray = field(default_factory=lambda: np.array([0,0,-9.81]))
|
||
|
||
nodes: Dict[str, SpatialNode] = field(default_factory=dict)
|
||
edges: List[SpatialEdge] = field(default_factory=list)
|
||
dense: DenseLayerRefs = field(default_factory=DenseLayerRefs)
|
||
anchors: List[Anchor] = field(default_factory=list)
|
||
deltas: List[DeltaEvent] = field(default_factory=list)
|
||
|
||
# —— 索引(运行时构建,不序列化)——
|
||
_kdtree: Any = None # 空间近邻
|
||
_faiss: Any = None # CLIP 向量
|
||
|
||
# —— 便捷方法 ——
|
||
def add_node(self, node: SpatialNode) -> None:
|
||
assert node.uid not in self.nodes, f"duplicate uid {node.uid}"
|
||
self.nodes[node.uid] = node
|
||
|
||
def add_edge(self, edge: SpatialEdge) -> None:
|
||
assert edge.src_uid in self.nodes
|
||
assert edge.dst_uid in self.nodes
|
||
self.edges.append(edge)
|
||
|
||
def nodes_of_level(self, lvl: MemoryLevel) -> List[SpatialNode]:
|
||
return [n for n in self.nodes.values() if n.level == lvl]
|
||
|
||
def nodes_in_room(self, room_uid: str) -> List[SpatialNode]:
|
||
return [n for n in self.nodes.values() if n.parent_room == room_uid]
|
||
```
|
||
|
||
---
|
||
|
||
## 3.2.1 v1.5 新增:KeyframeEvidence(per-frame 独立证据)
|
||
|
||
### 动机
|
||
|
||
v1.4 之前 PRISM 的 L2 是**融合表示**——TSDF 把每帧深度加权累计到体素、OctoMap 把每条 ray 更新到八叉树占据概率。融合的代价是**累计误差不可回溯**:一旦 L2 在长走廊里漂了 30 cm,"第 t=12.3 s 那一帧 ZED 看到桌子在哪"就再也拿不回来——它已经被融进了几百万个体素的加权平均里。
|
||
|
||
借鉴 Lyra 2.0 § 3.2(a)"**3D 缓存绝不融合(never fuse)**"的设计,v1.5 让每个 L3 节点额外保留 ≤ 5 个独立 keyframe 作为"翻案证据":当下游 L4 发现 bbox 估计与节点对不齐、或巩固期需要回溯某个语义改变是否真实发生时,可以**绕过已融合的 L2,直接从原始 keyframe 重新估计**。完整原则陈述与风险分析见 [`18_lyra_inspirations.md` § 18.3](18_lyra_inspirations.md)。
|
||
|
||
注意:这不是把 TSDF 扔掉,而是让 TSDF(路由 + 避障)与 keyframe 列表(翻案证据)**并存**——二者承担不同任务、对几何精度有不同容忍度。
|
||
|
||
### 完整 `KeyframeEvidence` Dataclass
|
||
|
||
```python
|
||
# spatial_memory/schema.py (v1.5 新增,接在原 SpatialNode 之后)
|
||
@dataclass
|
||
class KeyframeEvidence:
|
||
"""
|
||
每个 L3 节点保留的 per-frame 独立证据。
|
||
一律 append-only,绝不被 L2 融合操作覆盖(immutable=True)。
|
||
|
||
设计要点:
|
||
- lazy load: depth_path / rgb_path 只存路径,原始张量留磁盘
|
||
- 视觉特征预先算好 CLIP 嵌入并 inline(只有 1.5 KB,索引快)
|
||
- visibility_score 是 retention 决策的唯一依据(见下文 retention 策略)
|
||
"""
|
||
# ── 标识 ──
|
||
kf_id: str # 全局唯一,匹配 stm/keyframes/{kf_id}/
|
||
ts: float # 采集时间戳 (epoch sec)
|
||
source_pipeline: Literal["C_online", "A_offline", "D_consolidation"]
|
||
# 该证据由哪条管线产生
|
||
|
||
# ── 几何 ──
|
||
T_cam_world: np.ndarray # (4,4) float32 相机→世界 SE(3)
|
||
pose_uncert: Optional[np.ndarray] = None # (6,6) 协方差,可空
|
||
|
||
# ── 内容引用(lazy load) ──
|
||
rgb_path: str = "" # 相对 robot_memory/ 的 JPEG 路径
|
||
depth_path: str = "" # 相对路径,uint16 PNG(mm 单位)
|
||
intrinsics: Optional[np.ndarray] = None # (3,3) 该帧内参 K
|
||
|
||
# ── 内联特征(小,索引友好) ──
|
||
clip_emb: Optional[np.ndarray] = None # (768,) float16, ~1.5 KB
|
||
|
||
# ── 路由元数据 ──
|
||
routed_l3: Optional[str] = None # 当时路由到的 L3 节点 uid
|
||
routed_l4: List[str] = field(default_factory=list) # 关联 L4 uid 列表
|
||
visibility_score: float = 0.0 # [0,1] 该帧对节点的可见度/信息量
|
||
# 用于 retention 时挑选最差帧替换
|
||
|
||
# ── 不变性保护 ──
|
||
fused_into_l2: bool = False # 是否曾参与 L2 TSDF 融合
|
||
immutable: bool = True # 写入后禁止修改;违反者 raise
|
||
```
|
||
|
||
字段从 [`18_lyra_inspirations.md` § 18.3](18_lyra_inspirations.md) 的 `KeyframeEvidence`
|
||
扩展而来:保留 Lyra 风格的 append-only 与 lazy-load 语义,并补齐了 PRISM
|
||
专用的 `rgb_path / intrinsics / visibility_score / source_pipeline` 字段以贴合
|
||
"L3 节点级别保留"这一更具体的工程目标。
|
||
|
||
### `SpatialNode` 字段扩展示例
|
||
|
||
在原 `SpatialNode` 定义(§ 3.2 中段,第 72 行起)的末尾新增**一个字段**,其他字段全部保留:
|
||
|
||
```python
|
||
@dataclass
|
||
class SpatialNode:
|
||
# ... 原 v1.0 所有字段保持不变(uid/label/level/source/confidence/
|
||
# pose/bbox_3d/.../parent_room/children) ...
|
||
|
||
# ── v1.5 新增 ──
|
||
keyframe_evidence: List[KeyframeEvidence] = field(default_factory=list)
|
||
# 最多保留 5 条,append-only;满后用 visibility_score 替换最差(见下)
|
||
```
|
||
|
||
序列化时 `keyframe_evidence` 走与 `clip_embedding` 同样的"大向量外链"策略:
|
||
JSON 中仅留 `kf_id + rgb_path + depth_path + visibility_score + ts` 等元数据,
|
||
`clip_emb` 落 `embeddings/{kf_id}.clip.npy`。
|
||
|
||
### 存储预算估算
|
||
|
||
按典型酒店楼层(500 L3+L4 节点、每节点保留 5 帧)做单帧 + 总量两级估算:
|
||
|
||
| 项 | 编码 | 单帧大小 | 备注 |
|
||
|----|------|----------|------|
|
||
| RGB | 320×240 JPEG q=85 | ~30 KB | ZED/iPhone 下采样后足够做事后翻案 |
|
||
| Depth | 320×240 uint16 PNG | ~50 KB | mm 单位,压缩率 ~3× |
|
||
| CLIP 嵌入 | 768D float16 | 1.5 KB | inline 进 JSON 索引 |
|
||
| 内参 + 位姿 + meta | JSON | ~0.5 KB | T_cam_world + intrinsics + pose_uncert |
|
||
| **单帧合计** | — | **~82 KB** | 取整 ≈ 80 KB |
|
||
|
||
| 维度 | 数值 | 总量 |
|
||
|------|------|------|
|
||
| 每节点保留帧数 | 5 | 5 × 80 KB ≈ **400 KB / 节点** |
|
||
| 节点数(典型一层) | 500 | 500 × 400 KB ≈ **200 MB / 楼层** |
|
||
| 节点数(大型场馆 5 层) | 2500 | 2500 × 400 KB ≈ **1.0 GB / 整馆** |
|
||
| 旁通存储(NVMe SSD) | — | 1 GB 完全可接受(snapshots/ 同盘) |
|
||
|
||
结论:**单层 ~200 MB,整馆 ~1 GB**,相比 `dense/octomap.bt`(典型 50–200 MB)
|
||
与 `dense/3dgs.ply`(典型 100 MB–1 GB / 房间)属于同量级,**没有引入新数量
|
||
级的存储瓶颈**。若磁盘吃紧,可把 `rgb_path` 进一步压到 160×120 JPEG
|
||
(~12 KB / 帧)把整馆压到 ~250 MB。
|
||
|
||
### Retention 策略(满 5 帧后如何替换)
|
||
|
||
`keyframe_evidence` 容量上限默认 **5 帧/节点**。新证据写入时若已满,按以下规则替换:
|
||
|
||
```python
|
||
def admit_evidence(node: SpatialNode, new_kf: KeyframeEvidence,
|
||
capacity: int = 5) -> None:
|
||
"""append-only 语义下的 admission control:
|
||
capacity 未满则直接 append;满则用 visibility_score 替换最差帧。
|
||
注意:被替换的 KeyframeEvidence 在 Pipeline D 巩固期归档到
|
||
robot_memory/snapshots/ 而非硬删,保留可追溯性。
|
||
"""
|
||
if len(node.keyframe_evidence) < capacity:
|
||
node.keyframe_evidence.append(new_kf)
|
||
return
|
||
# 已满:挑当前 visibility_score 最低的一帧
|
||
worst_idx = min(range(capacity),
|
||
key=lambda i: node.keyframe_evidence[i].visibility_score)
|
||
if new_kf.visibility_score > node.keyframe_evidence[worst_idx].visibility_score:
|
||
archive_to_snapshot(node.keyframe_evidence[worst_idx]) # 归档,不硬删
|
||
node.keyframe_evidence[worst_idx] = new_kf
|
||
# 否则新帧也不如最差帧好,直接丢弃(由 Pipeline D 决定是否进 snapshots/)
|
||
```
|
||
|
||
`visibility_score ∈ [0, 1]` 的计算遵循 **geometry-aware retrieval**
|
||
(Lyra 2.0 § 3.2(b)):综合 (a) 该帧对节点 OBB 的覆盖面积比例、
|
||
(b) 视角与已有保留帧的角度差异、(c) 深度有效像素占比,三者加权平均。
|
||
直觉是"保留信息互补、视角多样、深度可信的 5 帧",而不是"最近 5 帧"。
|
||
|
||
回链:本小节落实了 [`18_lyra_inspirations.md` § 18.3](18_lyra_inspirations.md)
|
||
"原则二:Per-frame 独立 keyframe 证据"在数据 schema 上的承接。
|
||
[`13_evaluation.md`](13_evaluation.md) 后续需新增"翻案命中率 / 翻案误报率"
|
||
两项指标来闭环验证本字段的工程价值。
|
||
|
||
---
|
||
|
||
## 3.3 序列化规范
|
||
|
||
### 3.3.1 JSON(人读 + Git 友好)
|
||
|
||
```python
|
||
# spatial_memory/io_json.py
|
||
import json
|
||
import numpy as np
|
||
from .schema import SpatialMemory, SpatialNode, SpatialEdge
|
||
|
||
def _np_encoder(o):
|
||
if isinstance(o, np.ndarray):
|
||
return {"__ndarray__": True, "dtype": str(o.dtype),
|
||
"shape": list(o.shape), "data": o.flatten().tolist()}
|
||
if isinstance(o, (np.float32, np.float16)):
|
||
return float(o)
|
||
raise TypeError(f"non-serializable: {type(o)}")
|
||
|
||
def _np_decoder(d):
|
||
if d.get("__ndarray__"):
|
||
return np.array(d["data"], dtype=d["dtype"]).reshape(d["shape"])
|
||
return d
|
||
|
||
def save(mem: SpatialMemory, path: str) -> None:
|
||
from dataclasses import asdict
|
||
blob = asdict(mem)
|
||
# 大向量不入 JSON:替换为 .npy 外链
|
||
for uid, node in blob["nodes"].items():
|
||
if node["clip_embedding"] is not None:
|
||
np.save(f"{path}.{uid}.clip.npy", node["clip_embedding"])
|
||
node["clip_embedding"] = {"__npy__": f"{uid}.clip.npy"}
|
||
with open(path, "w") as f:
|
||
json.dump(blob, f, indent=2, default=_np_encoder)
|
||
|
||
def load(path: str) -> SpatialMemory:
|
||
with open(path) as f:
|
||
blob = json.load(f, object_hook=_np_decoder)
|
||
# 反向恢复 dataclass
|
||
...
|
||
return SpatialMemory(**blob)
|
||
```
|
||
|
||
### 3.3.2 USD(与 Omniverse / Isaac Sim / Polycam 互通)
|
||
|
||
每个 L4 节点 → 一个 USD `Xform` prim;几何挂在子 prim:
|
||
|
||
```
|
||
/World/Hotel
|
||
/Room_301 (Xform, custom attr: room_label="Bedroom")
|
||
/Bed_301 (Xform, ref=bed.usd)
|
||
/TV_301 (Xform, ref=tv.usd)
|
||
/Hallway_3F
|
||
...
|
||
```
|
||
|
||
转换器:
|
||
|
||
```python
|
||
# spatial_memory/io_usd.py
|
||
from pxr import Usd, UsdGeom, Gf
|
||
|
||
def memory_to_usd(mem: SpatialMemory, usd_path: str):
|
||
stage = Usd.Stage.CreateNew(usd_path)
|
||
world = UsdGeom.Xform.Define(stage, "/World")
|
||
|
||
# L3 房间作为父 Xform
|
||
for room in mem.nodes_of_level(MemoryLevel.L3):
|
||
room_xform = UsdGeom.Xform.Define(stage, f"/World/{room.uid}")
|
||
room_xform.GetPrim().CreateAttribute(
|
||
"prism:label", Sdf.ValueTypeNames.String).Set(room.label)
|
||
|
||
for obj in mem.nodes_in_room(room.uid):
|
||
obj_xform = UsdGeom.Xform.Define(
|
||
stage, f"/World/{room.uid}/{obj.uid}")
|
||
T = obj.pose.to_matrix()
|
||
obj_xform.AddTransformOp().Set(Gf.Matrix4d(T.tolist()))
|
||
if obj.mesh_uri:
|
||
obj_xform.GetPrim().GetReferences().AddReference(obj.mesh_uri)
|
||
|
||
stage.GetRootLayer().Save()
|
||
```
|
||
|
||
### 3.3.3 二进制(生产部署)
|
||
|
||
为了在 Jetson 上加载快,关键张量走二进制:
|
||
|
||
| 数据 | 格式 | 工具 |
|
||
|------|------|------|
|
||
| 点云 | `.ply` / `.las` | Open3D |
|
||
| TSDF | `.vbg` | Open3D VoxelBlockGrid |
|
||
| 3DGS | `.ply` (gsplat 标准布局) | gsplat / nerfstudio |
|
||
| OctoMap | `.bt` | octomap-cpp |
|
||
| Mesh | `.glb` (gltf 2.0) | trimesh |
|
||
| CLIP 向量库 | `.faiss` | Faiss |
|
||
|
||
JSON 仅存**索引和元数据**,数据走文件引用。
|
||
|
||
---
|
||
|
||
## 3.4 磁盘目录布局(落盘规范)
|
||
|
||
```
|
||
robot_memory/
|
||
├── manifest.json # 总入口,含 schema_version & 各文件 SHA256
|
||
├── ltm/ # 长期记忆(iPhone 主导,写入后近乎只读)
|
||
│ ├── spatial_memory.json # SpatialMemory 主体(不含大向量)
|
||
│ ├── embeddings/
|
||
│ │ └── {uid}.clip.npy # 每节点 CLIP 向量
|
||
│ ├── meshes/
|
||
│ │ ├── room_301.glb
|
||
│ │ └── bed_301.glb
|
||
│ ├── pointcloud/
|
||
│ │ └── room_301.ply
|
||
│ ├── dense/
|
||
│ │ ├── octomap.bt
|
||
│ │ ├── tsdf.vbg
|
||
│ │ ├── 3dgs.ply
|
||
│ │ ├── prior_mask.npy
|
||
│ │ └── no_update_zone.npy
|
||
│ ├── anchors.json
|
||
│ └── usd/
|
||
│ └── hotel.usdz # 给 Omniverse / 仿真用
|
||
│
|
||
├── stm/ # 短期/工作记忆(运行时,环形覆盖)
|
||
│ ├── current_pose.txt # 单行最新位姿
|
||
│ ├── trajectory.tum # TUM 格式轨迹(追加写)
|
||
│ ├── keyframes/
|
||
│ │ └── {ts}_{idx}/
|
||
│ │ ├── rgb.jpg
|
||
│ │ ├── depth.png
|
||
│ │ └── meta.json
|
||
│ ├── live_octomap.bt # 实时局部
|
||
│ └── live_tsdf.vbg
|
||
│
|
||
├── delta/ # 差异记忆(待巩固)
|
||
│ ├── pending.jsonl # 一行一个 DeltaEvent
|
||
│ ├── confirmed.jsonl
|
||
│ └── rejected.jsonl
|
||
│
|
||
├── snapshots/ # 版本化历史 LTM
|
||
│ ├── 2026-05-16_v1.tar.zst
|
||
│ └── 2026-06-01_v2.tar.zst
|
||
│
|
||
└── logs/
|
||
├── relocalize.log
|
||
├── consolidation.log
|
||
└── metrics.parquet # 评测指标时间序列
|
||
```
|
||
|
||
`manifest.json` 示例:
|
||
|
||
```json
|
||
{
|
||
"schema_version": "1.0.0",
|
||
"world_frame": "map",
|
||
"created_at": "2026-05-16T10:00:00+08:00",
|
||
"scene_name": "Hotel-Demo-Floor3",
|
||
"ltm_version": 5,
|
||
"files": {
|
||
"ltm/spatial_memory.json": {
|
||
"sha256": "ab12...",
|
||
"size_bytes": 1048576
|
||
},
|
||
"ltm/dense/octomap.bt": { "sha256": "...", "size_bytes": 52428800 }
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3.5 数据库映射(生产部署用 Neo4j)
|
||
|
||
原型期可用 JSON + NetworkX,生产期建议把 L3+L4 进 Neo4j(节点 + 关系),稠密数据仍走文件:
|
||
|
||
```cypher
|
||
// 创建房间节点
|
||
MERGE (r:Room {uid: 'room_301'})
|
||
SET r.label='Bedroom',
|
||
r.center=point({x:1.2, y:3.4, z:0.0}),
|
||
r.confidence=0.95;
|
||
|
||
// 创建家具节点 + 关系
|
||
MERGE (b:Furniture {uid: 'bed_301'})
|
||
SET b.label='bed', b.mobile=false,
|
||
b.pose_x=2.0, b.pose_y=3.5, b.pose_z=0.3;
|
||
|
||
MERGE (r)-[:CONTAINS]->(b);
|
||
|
||
// 小物品 on 家具
|
||
MERGE (rc:Item {uid: 'remote_xyz'})
|
||
SET rc.label='remote_control', rc.mobile=true;
|
||
MERGE (rc)-[:ON {confidence:0.85}]->(b);
|
||
```
|
||
|
||
LLM Agent 查询时可以直接发 Cypher:
|
||
```cypher
|
||
MATCH (i:Item {label:'remote_control'})-[:ON]->(f)<-[:CONTAINS]-(r:Room)
|
||
RETURN i, f, r;
|
||
```
|
||
|
||
---
|
||
|
||
## 3.6 版本化与迁移
|
||
|
||
```python
|
||
# spatial_memory/migrate.py
|
||
def migrate(blob: dict) -> dict:
|
||
v = blob.get("schema_version", "0.0.0")
|
||
if v == "1.0.0":
|
||
return blob
|
||
if v == "0.9.0":
|
||
# 0.9 → 1.0:把 'class' 字段重命名为 'category'
|
||
for node in blob["nodes"].values():
|
||
node["category"] = node.pop("class", None)
|
||
blob["schema_version"] = "1.0.0"
|
||
return migrate(blob)
|
||
raise ValueError(f"Unsupported schema version: {v}")
|
||
```
|
||
|
||
每次 schema 升级 → 写一个迁移函数 + 在 `snapshots/` 留备份。
|
||
|
||
---
|
||
|
||
## 3.7 一致性校验
|
||
|
||
写完任何节点都跑:
|
||
|
||
```python
|
||
def validate(mem: SpatialMemory) -> List[str]:
|
||
errs = []
|
||
for uid, node in mem.nodes.items():
|
||
if uid != node.uid:
|
||
errs.append(f"uid mismatch: {uid} vs {node.uid}")
|
||
if node.level == MemoryLevel.L4 and node.parent_room is None:
|
||
errs.append(f"L4 node {uid} missing parent_room")
|
||
if node.confidence < 0 or node.confidence > 1:
|
||
errs.append(f"{uid} confidence out of [0,1]")
|
||
for edge in mem.edges:
|
||
if edge.src_uid not in mem.nodes:
|
||
errs.append(f"dangling edge src: {edge.src_uid}")
|
||
if edge.dst_uid not in mem.nodes:
|
||
errs.append(f"dangling edge dst: {edge.dst_uid}")
|
||
return errs
|
||
```
|
||
|
||
CI 里跑 `validate()` 防止 schema 退化。
|
||
|
||
---
|
||
|
||
## 3.8 API 设计原则(给上层 Agent)
|
||
|
||
不要让 Agent 直接访问 `mem.nodes`;提供高层方法:
|
||
|
||
```python
|
||
class SpatialMemoryAPI:
|
||
def find(self, text: str, top_k: int = 5) -> List[SpatialNode]: ...
|
||
def locate(self, query: SpatialNode) -> Pose: ...
|
||
def neighbors(self, uid: str, radius: float = 1.0) -> List[SpatialNode]: ...
|
||
def path_rooms(self, src_room: str, dst_room: str) -> List[str]: ...
|
||
def changes_since(self, t: float) -> List[DeltaEvent]: ...
|
||
def describe(self, uid: str) -> str:
|
||
"""生成自然语言描述供 LLM 消化"""
|
||
node = self.nodes[uid]
|
||
room = self.nodes[node.parent_room]
|
||
return f"A {node.attributes.get('color','')} {node.label} " \
|
||
f"in {room.label}, last seen {ago(node.last_seen)}."
|
||
```
|
||
|
||
→ Agent 看不见底层格式变更,只用 API。
|
||
|
||
---
|
||
|
||
## 3.9 本章小结
|
||
|
||
| 关键约定 | 一句话 |
|
||
|----------|--------|
|
||
| **根对象** | `SpatialMemory`(含 nodes/edges/dense/anchors/deltas) |
|
||
| **节点** | `SpatialNode`(带 level + source + confidence + 时间戳) |
|
||
| **边** | `SpatialEdge`(含 relation + weight) |
|
||
| **稠密数据** | 不入节点,走文件 URI |
|
||
| **磁盘** | 三主目录:`ltm/` `stm/` `delta/` + `snapshots/` |
|
||
| **数据库** | 原型 NetworkX/JSON,生产 Neo4j |
|
||
| **序列化** | JSON 主,二进制大数据走外链;USD 用于仿真桥接 |
|
||
| **版本** | `schema_version` + 迁移函数 |
|
||
|
||
读完本章你应能:
|
||
- ✅ 把 `schema.py` 复制进项目就开始写代码
|
||
- ✅ 知道一份 LTM 在磁盘上长什么样
|
||
- ✅ 给 LLM Agent 提供安全的 API
|
||
|
||
下一章 [`04_pipeline_A_iphone_offline.md`](04_pipeline_A_iphone_offline.md) 用这套 schema 实现"iPhone 扫描 → SpatialMemory 灌入"的完整管线。
|
||
|
||
---
|
||
|
||
**章节版本**:v1.0
|
||
**估计阅读时间**:15 分钟
|
||
**关键收获**:拿到可立即使用的 dataclass + 磁盘布局
|