一句话总结

3DGSNav 让视觉-语言模型(VLM)在陌生环境中找东西时,不再依赖”简化的语义地图”,而是用 3D 高斯泼溅(3DGS)构建持久的 3D 记忆,通过自由视角渲染来增强空间推理能力。

为什么这个问题重要?

想象你让机器人去找”冰箱里的牛奶”:

核心痛点

  1. 语义地图丢失了几何细节(形状、纹理、空间关系)
  2. VLM 只能看到当前视角,缺乏”回忆过去”的能力
  3. 目标物体可能被遮挡,需要主动切换视角验证

3DGSNav 的创新

背景知识

3D 表示方式对比

表示方式 存储内容 优点 缺点
语义地图 2D 网格 + 语义标签 轻量,易规划 丢失 3D 信息
点云 3D 点 + RGB 保留几何 渲染质量差
Mesh 三角面片 精确重建 重建耗时
3DGS 3D 高斯分布 实时渲染,保留外观 内存占用高

为什么选 3DGS?

3DGS 用一组 3D 高斯分布 表示场景: \(G(\mathbf{x}) = \alpha \cdot \exp\left(-\frac{1}{2}(\mathbf{x} - \boldsymbol{\mu})^T \Sigma^{-1} (\mathbf{x} - \boldsymbol{\mu})\right)\)

关键优势

VLM 在导航中的作用

VLM(如 GPT-4V)能理解图像 + 文本指令,但传统用法是”看一张图 → 做决策”。3DGSNav 的创新是让 VLM 看到:

  1. 历史轨迹渲染:回顾走过的地方
  2. 候选视角渲染:主动切换视角验证目标

核心方法

直觉解释

机器人接收任务:"找到沙发上的遥控器"

传统方法:
[当前视角] → VLM: "去客厅" → [移动] → [新视角] → VLM: "没看到,继续找"
问题:VLM 看不到之前走过的地方,可能重复搜索

3DGSNav:
[当前视角] → 更新 3DGS 记忆 → VLM 渲染"客厅候选视角" → 
VLM: "从侧面看,沙发扶手旁边有个黑色物体,可能是遥控器" → 主动移动验证

Pipeline 概览

输入:RGB-D 图像流 + 文本指令
  ↓
1. 增量式 3DGS 构建(实时更新)
  ↓
2. 前沿探测(Frontier Detection)
  ↓
3. 轨迹引导的自由视角渲染
  ↓
4. 结构化视觉提示 + CoT 推理
  ↓
5. VLM 决策:移动 or 切换视角
  ↓
输出:导航动作

数学细节

1. 增量式 3DGS 更新

每接收一帧 RGB-D 图像 $(I_t, D_t)$,提取新高斯点: \(\mathcal{G}_t = \{(\boldsymbol{\mu}_i, \Sigma_i, \mathbf{c}_i, \alpha_i)\}_{i=1}^{N_t}\)

全局高斯集合通过合并更新: \(\mathcal{G}_{\text{global}} \leftarrow \mathcal{G}_{\text{global}} \cup \mathcal{G}_t\)

去重策略:距离 $|\boldsymbol{\mu}_i - \boldsymbol{\mu}_j| < \tau$ 的高斯点合并。

2. 自由视角渲染

给定相机位姿 $\mathbf{T} = [\mathbf{R} \mid \mathbf{t}]$,渲染像素颜色: \(C(\mathbf{p}) = \sum_{i \in \mathcal{V}} \alpha_i \prod_{j<i}(1-\alpha_j) \cdot \mathbf{c}_i\)

其中 $\mathcal{V}$ 是深度排序后的可见高斯点。

3. CoT 提示设计

任务:找到 {target}
历史观察:[过去 5 帧的缩略图]
当前视角:[第一人称渲染图]
候选视角:[3 个前沿方向的渲染图]

请按以下步骤推理:
1. 目标特征:{target} 通常出现在什么位置?
2. 场景理解:当前在什么房间?
3. 视角选择:哪个候选视角最可能看到目标?
4. 决策:移动 or 切换视角?

实现

环境配置

# 安装依赖
pip install torch torchvision open3d gsplat trimesh

# 克隆 3DGS 渲染器(使用 gsplat 简化版)
git clone https://github.com/nerfstudio-project/gsplat
cd gsplat && pip install -e .

核心代码

1. 增量式 3DGS 构建

import torch
import numpy as np
from gsplat import rasterization

class Incremental3DGS:
    def __init__(self, device='cuda'):
        self.device = device
        # 存储全局高斯点:位置、协方差、颜色、不透明度
        self.means = torch.empty((0, 3), device=device)
        self.covs = torch.empty((0, 3, 3), device=device)
        self.colors = torch.empty((0, 3), device=device)
        self.opacities = torch.empty((0, 1), device=device)
        
    def add_frame(self, rgb, depth, K, T):
        """
        rgb: (H, W, 3) RGB 图像
        depth: (H, W) 深度图
        K: (3, 3) 相机内参
        T: (4, 4) 相机位姿(世界坐标系到相机坐标系)
        """
        H, W = depth.shape
        
        # 1. 反投影到 3D(相机坐标系)
        u, v = np.meshgrid(np.arange(W), np.arange(H))
        points_cam = np.stack([
            (u - K[0, 2]) * depth / K[0, 0],
            (v - K[1, 2]) * depth / K[1, 1],
            depth
        ], axis=-1)  # (H, W, 3)
        
        # 2. 转到世界坐标系
        T_inv = np.linalg.inv(T)
        points_world = points_cam @ T_inv[:3, :3].T + T_inv[:3, 3]
        
        # 3. 下采样(每 4 个像素取一个)
        mask = (depth > 0) & (depth < 10.0)  # 有效深度范围
        mask[::4, ::4] = False  # 稀疏采样
        
        new_means = torch.from_numpy(points_world[mask]).float().to(self.device)
        new_colors = torch.from_numpy(rgb[mask]).float().to(self.device) / 255.0
        
        # 4. 初始化协方差(球形高斯,半径 = 0.01m)
        N = new_means.shape[0]
        new_covs = torch.eye(3).unsqueeze(0).repeat(N, 1, 1).to(self.device) * 0.01**2
        new_opacities = torch.ones((N, 1), device=self.device) * 0.9
        
        # 5. 去重(简化版:距离阈值)
        if self.means.shape[0] > 0:
            dist = torch.cdist(new_means, self.means)  # (N_new, N_old)
            keep_mask = (dist.min(dim=1)[0] > 0.02)  # 保留距离 > 2cm 的点
            new_means = new_means[keep_mask]
            new_colors = new_colors[keep_mask]
            new_covs = new_covs[keep_mask]
            new_opacities = new_opacities[keep_mask]
        
        # 6. 合并到全局
        self.means = torch.cat([self.means, new_means], dim=0)
        self.colors = torch.cat([self.colors, new_colors], dim=0)
        self.covs = torch.cat([self.covs, new_covs], dim=0)
        self.opacities = torch.cat([self.opacities, new_opacities], dim=0)
        
        # 7. 限制总数(内存管理)
        if self.means.shape[0] > 500000:
            self.prune_far_points()
    
    def prune_far_points(self):
        """保留不透明度高的点"""
        keep_idx = torch.argsort(self.opacities.squeeze(), descending=True)[:500000]
        self.means = self.means[keep_idx]
        self.colors = self.colors[keep_idx]
        self.covs = self.covs[keep_idx]
        self.opacities = self.opacities[keep_idx]

2. 自由视角渲染

def render_novel_view(gs_model, K, T, H=480, W=640):
    """
    从任意位姿 T 渲染场景
    返回: (H, W, 3) RGB 图像
    """
    # 准备相机参数
    viewmat = torch.from_numpy(T).float().cuda()  # (4, 4)
    
    # gsplat 渲染(简化调用)
    rendered_rgb, _, _ = rasterization(
        means=gs_model.means,
        quats=cov_to_quat(gs_model.covs),  # 协方差 → 四元数
        scales=torch.ones_like(gs_model.means) * 0.01,
        opacities=gs_model.opacities,
        colors=gs_model.colors,
        viewmats=viewmat.unsqueeze(0),
        Ks=torch.from_numpy(K).float().cuda().unsqueeze(0),
        width=W,
        height=H,
    )
    
    return rendered_rgb[0].cpu().numpy()  # (H, W, 3)

def cov_to_quat(covs):
    """协方差矩阵 → 四元数(简化版:假设各向同性)"""
    # ... (省略特征值分解代码)
    return torch.tensor([1, 0, 0, 0]).repeat(covs.shape[0], 1).cuda()

3. 前沿探测 + 候选视角生成

import cv2

def detect_frontiers(occupancy_map, robot_pos):
    """
    occupancy_map: (H, W) 0=未知, 1=空闲, 2=占据
    robot_pos: (x, y) 机器人当前位置(网格坐标)
    返回: 候选前沿方向列表 [(angle_1, score_1), ...]
    """
    # 1. 找到未知-空闲边界
    kernel = np.ones((5, 5), np.uint8)
    frontier = cv2.morphologyEx(
        (occupancy_map == 0).astype(np.uint8),
        cv2.MORPH_GRADIENT,
        kernel
    ) & (occupancy_map == 1)
    
    # 2. 聚类前沿点
    frontier_points = np.argwhere(frontier > 0)
    if len(frontier_points) == 0:
        return []
    
    # 3. 计算每个前沿方向的得分
    candidates = []
    for angle in np.linspace(0, 2*np.pi, 8, endpoint=False):
        direction = np.array([np.cos(angle), np.sin(angle)])
        # 统计该方向上的前沿点数
        scores = np.dot(frontier_points - robot_pos, direction)
        score = (scores > 0).sum()
        candidates.append((angle, score))
    
    # 4. 返回得分最高的 3 个方向
    candidates.sort(key=lambda x: x[1], reverse=True)
    return candidates[:3]

def generate_candidate_poses(robot_pose, frontier_angles, distance=1.0):
    """
    生成候选相机位姿
    robot_pose: (4, 4) 当前位姿
    frontier_angles: [(angle, score), ...]
    返回: [T1, T2, T3] 候选位姿列表
    """
    poses = []
    for angle, _ in frontier_angles:
        # 平移 distance 米
        T = robot_pose.copy()
        T[0, 3] += distance * np.cos(angle)
        T[1, 3] += distance * np.sin(angle)
        # 旋转朝向该方向
        T[:3, :3] = rotation_matrix_from_angle(angle)
        poses.append(T)
    return poses

def rotation_matrix_from_angle(angle):
    """2D 角度 → 3D 旋转矩阵(绕 Z 轴)"""
    c, s = np.cos(angle), np.sin(angle)
    return np.array([
        [c, -s, 0],
        [s,  c, 0],
        [0,  0, 1]
    ])

4. VLM 推理接口

import base64
from io import BytesIO
from PIL import Image

def query_vlm(images, prompt, api_key):
    """
    调用 VLM API(以 OpenAI GPT-4V 为例)
    images: 图像列表
    prompt: 文本提示
    返回: VLM 的文本回复
    """
    import openai
    openai.api_key = api_key
    
    # 转换图像为 base64
    image_urls = []
    for img in images:
        buffered = BytesIO()
        Image.fromarray((img * 255).astype(np.uint8)).save(buffered, format="PNG")
        img_str = base64.b64encode(buffered.getvalue()).decode()
        image_urls.append(f"data:image/png;base64,{img_str}")
    
    # 构造消息
    messages = [
        {
            "role": "user",
            "content": [
                {"type": "text", "text": prompt},
                *[{"type": "image_url", "image_url": {"url": url}} for url in image_urls]
            ]
        }
    ]
    
    response = openai.ChatCompletion.create(
        model="gpt-4-vision-preview",
        messages=messages,
        max_tokens=300
    )
    
    return response.choices[0].message.content

5. 主循环

import torch
import numpy as np
from gsplat import rasterization

class Incremental3DGS:
    def __init__(self, device='cuda'):
        self.device = device
        # 存储全局高斯点:位置、协方差、颜色、不透明度
        self.means = torch.empty((0, 3), device=device)
        self.covs = torch.empty((0, 3, 3), device=device)
        self.colors = torch.empty((0, 3), device=device)
        self.opacities = torch.empty((0, 1), device=device)
        
    def add_frame(self, rgb, depth, K, T):
        """增量添加新帧"""
        # 1. 反投影到 3D(相机坐标系)
        # ... (像素网格生成代码省略)
        points_cam = np.stack([...], axis=-1)
        
        # 2. 转到世界坐标系
        T_inv = np.linalg.inv(T)
        points_world = points_cam @ T_inv[:3, :3].T + T_inv[:3, 3]
        
        # 3. 下采样 + 颜色提取
        # ... (掩码过滤代码省略)
        new_means = torch.from_numpy(points_world[mask]).float().to(self.device)
        new_colors = torch.from_numpy(rgb[mask]).float().to(self.device) / 255.0
        
        # 4. 初始化协方差(球形高斯)
        N = new_means.shape[0]
        new_covs = torch.eye(3).unsqueeze(0).repeat(N, 1, 1).to(self.device) * 0.01**2
        new_opacities = torch.ones((N, 1), device=self.device) * 0.9
        
        # 5. 去重(距离阈值)
        if self.means.shape[0] > 0:
            dist = torch.cdist(new_means, self.means)
            keep_mask = (dist.min(dim=1)[0] > 0.02)
            new_means, new_colors, new_covs, new_opacities = \
                new_means[keep_mask], new_colors[keep_mask], new_covs[keep_mask], new_opacities[keep_mask]
        
        # 6. 合并到全局
        self.means = torch.cat([self.means, new_means], dim=0)
        self.colors = torch.cat([self.colors, new_colors], dim=0)
        self.covs = torch.cat([self.covs, new_covs], dim=0)
        self.opacities = torch.cat([self.opacities, new_opacities], dim=0)
        
        # 7. 内存管理(限制总数)
        if self.means.shape[0] > 500000:
            self.prune_far_points()
    
    def prune_far_points(self):
        """保留高不透明度点"""
        keep_idx = torch.argsort(self.opacities.squeeze(), descending=True)[:500000]
        self.means = self.means[keep_idx]
        # ... (其他属性同步裁剪省略)

3D 可视化

import open3d as o3d

def visualize_3dgs(gs_model):
    """可视化当前 3DGS 点云"""
    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(gs_model.means.cpu().numpy())
    pcd.colors = o3d.utility.Vector3dVector(gs_model.colors.cpu().numpy())
    
    # 添加坐标系
    coordinate_frame = o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5)
    
    o3d.visualization.draw_geometries([pcd, coordinate_frame])

实验

数据集说明

论文使用了三个基准数据集:

数据集 场景数 目标类别 数据格式
HM3D 800+ 80 类日常物体 RGB-D + 语义标注
Gibson 572 开放词汇 RGB-D + Mesh
MP3D 90 40 类家具 RGB-D + 语义

数据获取

定量评估

方法 Success Rate (%) SPL Steps 推理时间 (s)
3DGSNav 67.3 0.42 23.5 1.2
L3MVN 58.6 0.35 31.2 0.8
CoWs 51.2 0.28 38.7 1.5
CLIP-Nav 43.5 0.22 45.3 0.5

指标说明

定性结果

成功案例

失败案例

工程实践

实际部署考虑

1. 实时性分析

模块 耗时 (ms) 优化方案
3DGS 更新 150 GPU 加速 + 延迟更新
渲染候选视角 200 降低分辨率(320x240)
VLM 推理 1200 批量渲染,单次查询
总计 1550 → 0.6 Hz 决策频率

优化策略

2. 硬件需求

最低配置:
- GPU: RTX 3060 (12GB)
- CPU: Intel i7 (4 核)
- 内存: 16GB

推荐配置:
- GPU: RTX 4090 (24GB)  → 支持 100 万个高斯点
- CPU: Ryzen 9 (16 核)
- 内存: 32GB

3. 内存占用估算

每个高斯点占用: \(\text{Memory} = 3 \times 4 + 9 \times 4 + 3 \times 4 + 1 \times 4 = 64 \text{ bytes}\)

大场景策略

数据采集建议

1. RGB-D 相机选择

型号 深度范围 FPS 适用场景
RealSense D435 0.3-3m 30 室内导航
Kinect v2 0.5-4.5m 30 大房间
Ouster OS1 0.5-120m 10 室外

推荐配置:D435 + IMU(用于位姿估计)

2. 采集时的注意事项

常见坑

1. 3DGS 飘移问题

现象:长时间导航后,3DGS 几何扭曲

原因:累积位姿误差 + 缺乏闭环检测

解决方案

def detect_frontiers(occupancy_map, robot_pos):
    """检测未知-空闲边界,返回候选前沿方向"""
    # 1. 形态学梯度找边界
    kernel = np.ones((5, 5), np.uint8)
    frontier = cv2.morphologyEx(
        (occupancy_map == 0).astype(np.uint8),
        cv2.MORPH_GRADIENT, kernel
    ) & (occupancy_map == 1)
    
    frontier_points = np.argwhere(frontier > 0)
    if len(frontier_points) == 0:
        return []
    
    # 2. 8方向评分
    candidates = []
    for angle in np.linspace(0, 2*np.pi, 8, endpoint=False):
        direction = np.array([np.cos(angle), np.sin(angle)])
        scores = np.dot(frontier_points - robot_pos, direction)
        score = (scores > 0).sum()
        candidates.append((angle, score))
    
    return sorted(candidates, key=lambda x: x[1], reverse=True)[:3]

def generate_candidate_poses(robot_pose, frontier_angles, distance=1.0):
    """根据前沿角度生成候选位姿"""
    poses = []
    for angle, _ in frontier_angles:
        T = robot_pose.copy()
        T[:2, 3] += distance * np.array([np.cos(angle), np.sin(angle)])
        T[:3, :3] = np.array([[np.cos(angle), -np.sin(angle), 0],
                               [np.sin(angle),  np.cos(angle), 0],
                               [0, 0, 1]])
        poses.append(T)
    return poses

2. VLM 幻觉

现象:VLM 声称”看到目标”,实际是误识别

解决方案

def query_vlm(images, prompt, api_key):
    """调用 VLM API"""
    import openai
    openai.api_key = api_key
    
    # 转换图像为 base64
    image_urls = []
    for img in images:
        # ... (图像编码代码省略)
        image_urls.append(f"data:image/png;base64,{img_str}")
    
    # 构造消息
    messages = [{
        "role": "user",
        "content": [
            {"type": "text", "text": prompt},
            *[{"type": "image_url", "image_url": {"url": url}} for url in image_urls]
        ]
    }]
    
    response = openai.ChatCompletion.create(model="gpt-4-vision-preview", messages=messages)
    return response.choices[0].message.content

3. 内存爆炸

现象:探索 10 分钟后 GPU OOM

解决方案

def navigate(target_object, gs_model, robot, vlm_api_key):
    """基于VLM和3DGS的主动导航"""
    max_steps = 100
    
    for step in range(max_steps):
        # 1. 获取观察并更新3DGS
        rgb, depth, K, T = robot.get_observation()
        gs_model.add_frame(rgb, depth, K, T)
        
        # 2. 检测前沿
        frontiers = detect_frontiers(robot.get_occupancy_map(), robot.get_position())
        if len(frontiers) == 0:
            break
        
        # 3. 渲染候选视角
        candidate_poses = generate_candidate_poses(T, frontiers)
        candidate_views = [render_novel_view(gs_model, K, pose) for pose in candidate_poses]
        
        # 4. VLM推理
        prompt = f"任务:找到 {target_object}\n当前视角+候选视角A/B/C,选择最可能看到目标的视角"
        response = query_vlm([rgb] + candidate_views, prompt, vlm_api_key)
        
        # 5. 执行决策
        if "Found" in response:
            return True
        elif "A" in response:
            robot.move_to(candidate_poses[0])
        # ... (B/C分支类似)
    
    return False

什么时候用 / 不用?

适用场景 不适用场景
✅ 静态室内环境 ❌ 人流密集场景(遮挡多)
✅ 需要精细识别(小物体) ❌ 快速响应(< 1s 决策)
✅ 有 RGB-D 传感器 ❌ 纯视觉(无深度)
✅ 长时间探索任务 ❌ 一次性短任务(开销大)

与其他方法对比

方法 场景表示 优点 缺点 适用场景
语义地图 2D 网格 轻量,规划快 丢失 3D 信息 简单环境
NeRF 隐式函数 渲染质量高 慢(分钟级) 离线重建
3DGS (本文) 显式高斯 实时 + 高质量 内存占用高 复杂室内
Point-LLM 点云 几何精确 外观信息少 工业场景

我的观点

技术亮点

  1. 3DGS 作为持久记忆 是个绝妙的点子:
    • 相比语义地图,保留了完整的 3D 外观
    • 相比 NeRF,渲染速度快 100 倍
    • 让 VLM 能”回忆”和”预览”,这是人类导航的核心能力
  2. 主动视角切换 解决了单目视角的盲区问题:
    • 传统方法只能”走到那里才能看”
    • 3DGSNav 可以”想象走到那里会看到什么”

距离实用还有多远?

短期(1-2 年)可行的场景

长期挑战

  1. 动态环境:人/宠物走动时,3DGS 会包含”鬼影”
    • 可能方向:结合动态 3DGS(加时间维度)
  2. 大规模场景:整栋楼的内存占用 > 100GB
    • 可能方向:层次化 3DGS(类似 Octree)
  3. VLM 可靠性:目前 VLM 仍有 15% 的”幻觉率”
    • 可能方向:多模态验证(触觉 + 视觉)

值得关注的开放问题

  1. 3DGS 的语义理解:能否直接在高斯点上做语义分割?
  2. 在线 SLAM + 3DGS:如何在移动中实时优化?
  3. VLM 的空间推理能力:训练数据中 3D 样本太少

个人预测:3DGS 会成为具身智能的”标准中间表示”,就像 Transformer 之于 NLP。