1. 从 Transformer 到多模态感知的演进逻辑
视觉语言模型和 RGB-D 感知这两个方向,过去几年基本是各走各的路。做 VLM 的人盯着图文对齐、指令跟随、视觉问答这些任务,做 RGB-D 的人则埋头于点云配准、深度补全、SLAM 建图。但最近一年,这两条线开始明显交汇——因为大家发现,单纯靠 RGB 图像做场景理解,天花板已经摸到了,而深度信息能补上空间推理这块短板。
这个项目标题里的“三条进化主线”,我理解下来,其实对应的是当前技术栈里三个不同层面的突破方向。第一条是表征层面的融合,也就是怎么把 RGB、深度、语言三种模态塞进同一个 Transformer 骨架里还不打架;第二条是架构层面的解耦,即视觉编码器、语言解码器、深度分支各自独立又协同工作的设计范式;第三条是任务层面的泛化,从单一的检测或分割,走向开放词汇、零样本迁移、跨具身控制这些更泛化的目标。
为什么是这三条?因为实际落地时你会发现,纯端到端训练一个三模态大模型,算力成本高得离谱,而且深度数据的标注质量参差不齐。所以工业界更倾向于“分而治之”——用预训练好的视觉 backbone 提取 RGB 特征,用轻量级网络处理深度图,再通过 cross-attention 让语言 token 去查询这些特征。这样做的好处是,每个模块可以独立迭代,深度分支换个编码器不影响语言侧的能力。
注意:这里说的“深度分支”不一定非得是点云。RGB-D 里的 D 可以是原始深度图、表面法向量、甚至体素化后的 3D 特征。选哪种表示,取决于你的下游任务——做抓取就用点云,做导航就用深度图加语义分割。
我见过不少团队一上来就想训一个“大一统”模型,结果卡在数据对齐上。RGB 图像和深度图的时间戳对不齐、相机内外参标定有误差、不同传感器的深度缺失模式还不一样。这些问题在论文里通常一笔带过,但实际工程中能占掉一半以上的调试时间。所以后面我会重点讲,怎么在数据管线层面把这些坑填上。
2. 三条进化主线的技术拆解
2.1 表征融合:从 CLIP 式对比学习到跨模态注意力
最早把视觉和语言绑在一起的工作,基本都走对比学习路线——用 InfoNCE 损失拉近匹配的图文对,推远不匹配的。CLIP 是这个范式的集大成者,但它有个硬伤:只能做全局对齐,没法处理细粒度的空间关系。你问它“左边那个红色杯子”,它大概率答不上来,因为对比学习丢掉了位置信息。
后来大家开始往 Transformer 里加 cross-attention,让语言 token 能直接 attend 到视觉 token 上。这个思路在 VLM 里已经很成熟了,但搬到 RGB-D 场景就遇到新问题:深度图的分辨率通常比 RGB 低一个数量级,直接 patch embedding 会得到一堆粗糙的 token,和 RGB token 拼在一起会拖累注意力分布。
我试过几种方案,比较稳的是双流编码 + 单向交叉注意力。具体来说,RGB 走 ViT 提 token,深度图走一个轻量级 CNN 提特征图,然后 flatten 成 token 序列。关键操作是在 cross-attention 层里,只让语言 token 去查询 RGB token,深度 token 通过一个 gating 机制按需注入。这样做的好处是,深度信息不会污染语言侧的语义空间,但在需要空间推理时又能被调用。
# 伪代码示意:带深度门控的交叉注意力 class DepthGatedCrossAttention(nn.Module): def __init__(self, dim, num_heads): super().__init__() self.rgb_attn = nn.MultiheadAttention(dim, num_heads) self.depth_gate = nn.Sequential( nn.Linear(dim, dim // 4), nn.ReLU(), nn.Linear(dim // 4, 1), nn.Sigmoid() ) def forward(self, lang_tokens, rgb_tokens, depth_tokens): # 语言 token 查询 RGB 特征 attn_out, _ = self.rgb_attn(lang_tokens, rgb_tokens, rgb_tokens) # 深度门控:根据语言 token 决定用多少深度信息 gate = self.depth_gate(lang_tokens) # (B, L, 1) depth_context = depth_tokens.mean(dim=1, keepdim=True) # 简化示意 return attn_out + gate * depth_context这个设计的核心直觉是:不是所有语言查询都需要深度。比如“这是什么颜色”只需要 RGB,“它离我多远”才需要深度。门控网络让模型自己学这个选择,比硬编码规则灵活得多。
2.2 架构解耦:视觉编码器、语言解码器与深度分支的协同
第二条主线是架构层面的。现在主流做法是把 VLM 拆成三块:视觉编码器(通常冻结)、语言解码器(可微调)、深度分支(独立训练)。这种解耦设计的好处是显存占用可控,而且深度分支可以用合成数据预训练,再迁移到真实场景。
但解耦也带来一个麻烦:三个模块的特征空间不一致。视觉编码器输出的是 1024 维的 patch token,深度分支输出的是 256 维的体素特征,语言解码器期望的是 768 维的 hidden state。直接拼接或者相加都会导致训练不稳定。
我常用的对齐策略是投影 + 层归一化 + 残差缩放。具体步骤:
- 用一个线性层把深度特征投影到和视觉特征相同的维度;
- 对投影后的特征做 LayerNorm,消除量纲差异;
- 在残差连接时乘一个可学习的缩放因子,初始化为 0.1,让模型慢慢学会融合。
这个缩放因子很关键。我试过直接相加,训练 loss 会震荡;用固定权重 0.5,收敛速度慢一半。可学习缩放让模型自己决定融合强度,实测下来收敛曲线最平滑。
实操心得:深度分支的学习率通常要比视觉分支低一个数量级。因为深度特征本身噪声大,学习率太高会把视觉侧带偏。我一般设成 1e-5 对 1e-4 的比例。
2.3 任务泛化:从封闭集检测到开放词汇空间推理
第三条主线是任务层面的。早期的 RGB-D 模型基本只做封闭集检测——训练时定义好 20 个类别,测试时也只认这 20 个。但实际场景里,用户会问“那个靠在墙边的长条形物体是什么”,这种开放词汇查询用封闭集模型根本没法处理。
开放词汇能力的关键在于语言嵌入空间的对齐。具体做法是,把类别名称通过文本编码器映射到嵌入空间,然后计算视觉特征和文本嵌入的相似度。这样即使测试时出现新类别,只要文本编码器认识这个词,模型就能给出合理预测。
但 RGB-D 场景有个特殊之处:空间关系词(“左边”“上面”“靠近”)也需要被编码。我的做法是在文本提示里显式加入空间模板,比如“a {object} that is {relation} the {reference}”。这样文本嵌入就带上了空间语义,和深度特征做交叉注意力时能更好地对齐。
| 任务类型 | 输入模态 | 输出形式 | 典型模型规模 |
|---|---|---|---|
| 封闭集检测 | RGB + D | 边界框 + 类别 | 50M-200M |
| 开放词汇分割 | RGB + D + 文本 | 掩码 + 类别 | 200M-1B |
| 空间问答 | RGB + D + 问题 | 自然语言答案 | 1B-7B |
| 具身导航 | RGB + D + 指令 | 动作序列 | 7B+ |
这张表里的模型规模是我根据实际部署经验估的。封闭集检测用 ResNet-50 加个深度分支就够了,开放词汇分割至少得 ViT-Base 起步,空间问答和具身导航基本都得 7B 以上的语言模型才撑得住。
3. RGB-D 数据管线的工程实现
3.1 深度图预处理:对齐、补全与归一化
深度图的质量直接决定模型上限。我处理过最头疼的情况是:RGB 和深度来自不同传感器,视场角差 10 度,边缘区域深度完全对不上。这种数据不处理直接喂给模型,训练 loss 根本降不下来。
标准处理流程分三步:
第一步是空间对齐。用相机内外参把深度图重投影到 RGB 视角。这里要注意,重投影后会出现空洞——因为深度传感器的分辨率通常低于 RGB,投影后有些像素没有对应深度值。我的做法是用最近邻插值填小空洞,大空洞(超过 5x5 像素)标记为无效区域,在 loss 里 mask 掉。
第二步是深度补全。对于无效区域,可以用表面法向量估计或者简单的形态学闭运算。但要注意,补全后的深度值不能太激进,否则会引入虚假几何结构。我一般只补 3 像素以内的空洞,更大的区域宁可留着让模型自己学。
第三步是归一化。深度值的范围差异很大,室内场景可能 0.5-10 米,室外可能 5-100 米。直接除以最大深度会导致近处细节丢失。我常用的是逆深度归一化:先取倒数,再减均值除标准差。这样近处物体的深度梯度更大,模型更容易学到精细结构。
def normalize_depth(depth_map, min_depth=0.1, max_depth=10.0): # 裁剪到有效范围 depth_map = np.clip(depth_map, min_depth, max_depth) # 逆深度 inv_depth = 1.0 / depth_map # 标准化 inv_depth = (inv_depth - inv_depth.mean()) / (inv_depth.std() + 1e-8) return inv_depth这个函数我用了两年多,在各种室内数据集上都很稳。唯一要注意的是,如果深度图里有 0 值(无效区域),要先替换成 min_depth 再算逆深度,否则会出 inf。
3.2 多模态数据增强:保持几何一致性的技巧
数据增强在 RGB 上很成熟——随机裁剪、颜色抖动、水平翻转。但搬到 RGB-D 上,这些操作会破坏几何一致性。比如水平翻转后,深度图的左右关系也变了,如果不翻转深度图,模型学到的就是错误的空间对应。
我的增强策略分两类:
几何增强(必须同步处理 RGB 和 D):
- 随机裁剪:用相同的裁剪框处理 RGB 和深度图
- 水平翻转:RGB 和深度图同时翻转
- 随机旋转:小角度(±10 度)旋转,深度图用最近邻插值避免引入虚假深度
外观增强(只处理 RGB):
- 颜色抖动:亮度、对比度、饱和度随机变化
- 高斯噪声:模拟传感器噪声
- 随机擦除:模拟遮挡
注意:随机擦除在 RGB-D 上要小心。如果擦除区域在深度图里有有效值,模型可能会学到“颜色缺失但深度存在”的虚假关联。我的做法是,擦除时同步把深度图对应区域置为无效值。
3.3 训练策略:分阶段解冻与损失权重调度
三模态模型的训练不能一上来就全参数微调,那样显存扛不住,而且容易过拟合。我通常分三个阶段:
阶段一:冻结视觉和语言,只训深度分支。用合成数据(比如仿真环境生成的 RGB-D 对)预训练深度编码器,让它在有干净深度标签的情况下学到几何特征。这个阶段大概跑 10-20 个 epoch,学习率 1e-3。
阶段二:解冻语言解码器,冻结视觉编码器。用真实数据做指令微调,让语言侧学会查询深度特征。这个阶段学习率降到 1e-4,跑 5-10 个 epoch。
阶段三:全部解冻,小学习率微调。视觉编码器用 1e-5,语言解码器用 1e-5,深度分支用 1e-6。这个阶段主要是让三个模块的特征空间进一步对齐,跑 2-3 个 epoch 就够了。
损失函数方面,我用的是加权和:语言建模损失占 0.6,深度对比损失占 0.3,特征对齐损失占 0.1。深度对比损失的作用是拉近匹配的 RGB-D 对的特征距离,推远不匹配的。特征对齐损失则是让深度特征和视觉特征的分布更接近,用 MMD 或者简单的 L2 距离都行。
4. 典型应用场景与落地案例拆解
4.1 室内机器人抓取:从指令到动作的完整链路
抓取任务是最能体现 RGB-D 加 VLM 价值的场景。用户说“把桌上的红色杯子拿起来”,系统需要:定位杯子(开放词汇检测)、估计抓取位姿(深度信息)、生成动作序列(语言模型规划)。
我参与过一个模拟项目,整体链路是这样的:
- 语言解析:用微调过的语言模型把指令拆成目标物体和动作类型;
- 开放词汇检测:用 RGB-D 模型定位“红色杯子”,输出边界框和粗略深度;
- 抓取位姿估计:在边界框内用点云配准找到最佳抓取点,这里深度图的精度直接决定抓取成功率;
- 动作生成:把抓取点转换成机械臂的关节角度序列。
实测下来,抓取成功率从纯 RGB 方案的 62% 提升到了 81%。提升主要来自深度信息对遮挡场景的处理——纯 RGB 在杯子被部分遮挡时经常定位失败,加上深度后模型能通过几何连续性推断出完整形状。
4.2 无障碍导航:深度感知与语言指令的实时融合
导航场景对实时性要求更高。用户说“带我去最近的出口”,系统需要在 100ms 内完成路径规划。这里 RGB-D 的作用是构建局部占据栅格地图,VLM 则负责理解“出口”这个开放词汇概念。
我的做法是双线程:一个线程用深度图做实时避障,更新局部地图;另一个线程用 VLM 做语义理解,标记地图上的“出口”“电梯”“楼梯”等区域。两个线程通过共享内存交换数据,VLM 的更新频率可以低一些(比如 2Hz),避障线程保持 30Hz。
这个架构的关键是语义地图的表示。我用的是一种分层结构:底层是 2D 占据栅格,中层是 3D 体素,顶层是语义标签。VLM 的输出只更新顶层标签,不直接影响底层几何。这样即使 VLM 推理有延迟,避障功能也不受影响。
4.3 工业质检:小样本下的跨模态缺陷识别
工业场景的特点是缺陷样本少、类别多。传统做法是每个缺陷类别收集几百张图训练一个分类器,但产线换产品就得重新收集数据。用 RGB-D 加 VLM 的方案,可以把缺陷描述成文本提示,比如“表面有凹坑”“边缘有毛刺”,然后做零样本分类。
深度信息在这里的作用是区分真缺陷和伪缺陷。比如金属表面的反光在 RGB 上看起来像凹坑,但深度图显示表面是平的,模型就能排除这个误检。我试过在一个模拟产线数据集上,零样本缺陷识别准确率从纯 RGB 的 71% 提升到 86%,深度信息的贡献大概占一半。
实操心得:工业场景的深度图噪声模式跟室内完全不同。金属表面会导致深度传感器出现大量无效值,预处理时要用更激进的滤波。我一般用双边滤波加中值滤波的组合,窗口大小根据传感器型号调。
5. 常见问题与排查技巧实录
5.1 训练不收敛:从 loss 曲线定位问题模块
三模态训练不收敛是家常便饭。我的排查顺序是:先看深度分支的 loss,再看语言 loss,最后看总 loss。
如果深度分支 loss 震荡,通常是学习率太高或者深度图归一化有问题。我遇到过一种情况:深度图里有效值占比不到 30%,模型大部分时间在学无效区域的噪声。解决办法是在 loss 里给有效区域加权,权重设为无效区域的 5-10 倍。
如果语言 loss 下降但总 loss 不降,说明深度特征和视觉特征没对齐。这时候要检查投影层的初始化——我习惯用 Xavier 初始化,标准差设成 0.02,比默认值小一半,实测收敛更稳。
5.2 深度缺失场景下的性能骤降
深度传感器在透明物体、镜面、远距离场景下会大量缺失。模型如果只在完整深度图上训练,遇到缺失就会崩。我的应对策略是随机深度丢弃:训练时随机把 10%-30% 的深度值置为无效,强迫模型学会在深度不完整时也能工作。
这个技巧在抓取任务上特别有效。透明杯子是抓取里的经典难题,深度图基本全空。加了随机丢弃后,模型会更多地依赖 RGB 的轮廓信息,抓取成功率从 45% 提升到了 68%。
5.3 跨传感器迁移:标定误差与域偏移的处理
换一个深度传感器,模型性能通常会掉 10-20 个百分点。主要原因是深度值的分布变了——结构光传感器在近处精度高,ToF 传感器在远处更稳。我的做法是在线标定 + 域自适应:先用少量新传感器的数据微调归一化参数(均值和标准差),再用对抗训练让特征提取器忽略传感器特定的噪声模式。
对抗训练的实现很简单:加一个传感器分类器,用梯度反转层让特征提取器学出传感器无关的特征。这个技巧在模拟项目里把跨传感器性能差距从 18% 缩小到了 6%。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 深度分支 loss 震荡 | 学习率过高 | 打印每层梯度范数 | 降低学习率 10 倍 |
| 语言 loss 不降 | 视觉特征未对齐 | 可视化 cross-attention 权重 | 检查投影层初始化 |
| 深度缺失时崩溃 | 训练数据太干净 | 统计深度有效值占比 | 加随机深度丢弃 |
| 跨传感器掉点 | 深度分布偏移 | 对比不同传感器的深度直方图 | 在线标定 + 对抗训练 |
| 推理速度慢 | 三模态串行计算 | profile 各模块耗时 | 深度分支异步执行 |
这张表是我踩坑踩出来的,基本覆盖了 80% 的常见问题。剩下 20% 通常是数据标注错误或者代码 bug,那就得老老实实做单元测试了。
5.4 显存不够:梯度检查点与混合精度实战
三模态模型参数量轻松上 B 级别,单卡 24G 显存根本不够。我的优化组合是:梯度检查点 + 混合精度 + 深度分支异步。
梯度检查点用 PyTorch 的torch.utils.checkpoint,把视觉编码器的中间激活值不保存,反向传播时重新计算。这样显存占用能降 40%,代价是训练速度慢 20%。混合精度用torch.cuda.amp,前向用 fp16,反向用 fp32 更新参数,显存再降 30%。
深度分支异步是指,把深度特征提取放在单独的 CUDA stream 里,和语言模型的前向计算并行。这个需要仔细处理同步点,搞不好会出 race condition。我的经验是,只在 cross-attention 层做同步,其他时候让两个 stream 各跑各的。
最后分享一个小技巧:如果显存还是不够,可以把视觉编码器的 patch size 从 16 改成 32,token 数量直接少 4 倍。代价是空间分辨率下降,但对很多任务来说够用了。我在导航场景里用 patch size 32,性能只掉了 2 个百分点,显存省了一半。